diff --git a/app/actions/remote/permalink.ts b/app/actions/remote/permalink.ts index 98b7d7231..19aa9b3b4 100644 --- a/app/actions/remote/permalink.ts +++ b/app/actions/remote/permalink.ts @@ -1,11 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {fetchMyChannelsForTeam} from '@actions/remote/channel'; import DatabaseManager from '@database/manager'; -import {getCommonSystemValues} from '@queries/servers/system'; -import {getTeamById, getTeamByName} from '@queries/servers/team'; -import {permalinkBadTeam} from '@utils/draft'; +import {getCurrentTeam} from '@queries/servers/team'; import {displayPermalink} from '@utils/permalink'; import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; @@ -21,30 +18,14 @@ export const showPermalink = async (serverUrl: string, teamName: string, postId: try { let name = teamName; let team: TeamModel | undefined; - const system = await getCommonSystemValues(database); if (!name || name === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) { - team = await getTeamById(database, system.currentTeamId); + team = await getCurrentTeam(database); if (team) { name = team.name; } } - if (!team) { - team = await getTeamByName(database, name); - if (!team) { - permalinkBadTeam(intl); - return {error: 'Bad Permalink team'}; - } - } - - if (team.id !== system.currentTeamId) { - const result = await fetchMyChannelsForTeam(serverUrl, team.id, true, 0, false, true); - if (result.error) { - return {error: result.error}; - } - } - - await displayPermalink(team.name, postId, openAsPermalink); + await displayPermalink(name, postId, openAsPermalink); return {error: undefined}; } catch (error) { diff --git a/app/components/illustrations/join_private_channel.tsx b/app/components/illustrations/join_private_channel.tsx new file mode 100644 index 000000000..26ecb36b7 --- /dev/null +++ b/app/components/illustrations/join_private_channel.tsx @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import * as React from 'react'; +import Svg, { + Path, + Mask, + G, + EMaskUnits, +} from 'react-native-svg'; + +type Props = { + theme: Theme; +} + +function SvgComponent({theme}: Props) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default SvgComponent; diff --git a/app/components/illustrations/join_public_channel.tsx b/app/components/illustrations/join_public_channel.tsx new file mode 100644 index 000000000..e3f859cf9 --- /dev/null +++ b/app/components/illustrations/join_public_channel.tsx @@ -0,0 +1,125 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import * as React from 'react'; +import Svg, { + Path, + Mask, + G, + EMaskUnits, +} from 'react-native-svg'; + +type Props = { + theme: Theme; +} + +function SvgComponent({theme}: Props) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default SvgComponent; diff --git a/app/components/illustrations/message_not_viewable.tsx b/app/components/illustrations/message_not_viewable.tsx new file mode 100644 index 000000000..82807dbde --- /dev/null +++ b/app/components/illustrations/message_not_viewable.tsx @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import * as React from 'react'; +import Svg, {Path, Ellipse} from 'react-native-svg'; + +type Props = { + theme: Theme; +} + +function SvgComponent({theme}: Props) { + return ( + + + + + + + + + + ); +} + +export default SvgComponent; diff --git a/app/components/markdown/markdown.tsx b/app/components/markdown/markdown.tsx index 8e6a6d87a..c95348aac 100644 --- a/app/components/markdown/markdown.tsx +++ b/app/components/markdown/markdown.tsx @@ -37,6 +37,7 @@ import type { type MarkdownProps = { autolinkedUrlSchemes?: string[]; baseTextStyle: StyleProp; + baseParagraphStyle?: StyleProp; blockStyles?: MarkdownBlockStyles; channelId?: string; channelMentions?: ChannelMentions; @@ -131,7 +132,7 @@ const Markdown = ({ enableInlineLatex, enableLatex, imagesMetadata, isEdited, isReplyPost, isSearchResult, layoutHeight, layoutWidth, location, mentionKeys, minimumHashtagLength = 3, onPostPress, postId, searchPatterns, - textStyles = {}, theme, value = '', + textStyles = {}, theme, value = '', baseParagraphStyle, }: MarkdownProps) => { const style = getStyleSheet(theme); @@ -419,7 +420,7 @@ const Markdown = ({ return ( - + {children} diff --git a/app/queries/servers/team.ts b/app/queries/servers/team.ts index cbb67db12..a6db7a42f 100644 --- a/app/queries/servers/team.ts +++ b/app/queries/servers/team.ts @@ -332,7 +332,7 @@ export const queryTeamsById = (database: Database, teamIds: string[]) => { return database.get(TEAM).query(Q.where('id', Q.oneOf(teamIds))); }; -export const queryTeamByName = async (database: Database, teamName: string) => { +export const queryTeamByName = (database: Database, teamName: string) => { return database.get(TEAM).query(Q.where('name', teamName)); }; diff --git a/app/screens/permalink/index.ts b/app/screens/permalink/index.ts index f49c6d8b3..f12601914 100644 --- a/app/screens/permalink/index.ts +++ b/app/screens/permalink/index.ts @@ -7,21 +7,38 @@ 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 {queryMyTeamsByIds, queryTeamByName} from '@queries/servers/team'; import {observeIsCRTEnabled} from '@queries/servers/thread'; import {WithDatabaseArgs} from '@typings/database/database'; import PostModel from '@typings/database/models/servers/post'; import Permalink from './permalink'; -type OwnProps = {postId: PostModel['id']} & WithDatabaseArgs; +type OwnProps = { + postId: PostModel['id']; + teamName?: string; +} & WithDatabaseArgs; -const enhance = withObservables([], ({database, postId}: OwnProps) => { +const enhance = withObservables([], ({database, postId, teamName}: OwnProps) => { const post = observePost(database, postId); + const team = teamName ? queryTeamByName(database, teamName).observe().pipe( + switchMap((ts) => { + const t = ts[0]; + return t ? t.observe() : of$(undefined); + }), + ) : of$(undefined); return { channel: post.pipe( switchMap((p) => (p ? p.channel.observe() : of$(undefined))), ), + isTeamMember: team.pipe( + switchMap((t) => (t ? queryMyTeamsByIds(database, [t.id]).observe() : of$(undefined))), + 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 bb063ece7..9275bbbdf 100644 --- a/app/screens/permalink/permalink.tsx +++ b/app/screens/permalink/permalink.tsx @@ -2,12 +2,13 @@ // See LICENSE.txt for license information. import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {BackHandler, Text, TouchableOpacity, View} from 'react-native'; +import {Alert, BackHandler, Text, TouchableOpacity, View} from 'react-native'; import Animated from 'react-native-reanimated'; import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; -import {switchToChannelById} from '@actions/remote/channel'; -import {fetchPostsAround} from '@actions/remote/post'; +import {fetchChannelById, joinChannel, switchToChannelById} from '@actions/remote/channel'; +import {fetchPostById, fetchPostsAround} from '@actions/remote/post'; +import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from '@actions/remote/team'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import Loading from '@components/loading'; @@ -15,7 +16,9 @@ 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 {useIsTablet} from '@hooks/device'; +import {getChannelById, getMyChannel} from '@queries/servers/channel'; import {dismissModal} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; @@ -24,11 +27,17 @@ 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; + teamName?: string; + isTeamMember?: boolean; + currentUserId: string; + currentTeamId: string; isCRTEnabled: boolean; postId: PostModel['id']; } @@ -48,6 +57,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ flex: 1, margin: 10, opacity: 1, + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), }, header: { alignItems: 'center', @@ -102,23 +113,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ fontWeight: '600', textAlignVertical: 'center', }, - errorContainer: { - alignItems: 'center', - justifyContent: 'center', - padding: 15, - }, - errorText: { - color: changeOpacity(theme.centerChannelColor, 0.4), - fontSize: 15, - }, - archiveIcon: { - color: theme.centerChannelColor, - fontSize: 16, - paddingRight: 20, - }, -})); +}), +); -function Permalink({channel, isCRTEnabled, postId}: Props) { +function Permalink({ + channel, + isCRTEnabled, + postId, + teamName, + isTeamMember, + currentUserId, + currentTeamId, +}: Props) { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const theme = useTheme(); @@ -126,6 +132,8 @@ function Permalink({channel, isCRTEnabled, postId}: Props) { 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; @@ -135,20 +143,113 @@ function Permalink({channel, isCRTEnabled, postId}: Props) { useEffect(() => { (async () => { - if (channel?.id) { - const data = await fetchPostsAround(serverUrl, channel.id, postId, 5, isCRTEnabled); + if (channelId) { + const data = await fetchPostsAround(serverUrl, channelId, postId, 5, isCRTEnabled); + if (data.error) { + setError({unreachable: true}); + } if (data?.posts) { - setLoading(false); setPosts(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 addUserToTeam(serverUrl, joinedTeam.id, currentUserId); + if (addData.error) { + joinedTeam = undefined; + } + } + } + + const {post} = await fetchPostById(serverUrl, postId, true); + if (!post) { + if (joinedTeam) { + removeUserFromTeam(serverUrl, joinedTeam.id, currentUserId); + } + 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) { + removeUserFromTeam(serverUrl, joinedTeam.id, currentUserId); + 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) { + removeUserFromTeam(serverUrl, joinedTeam.id, currentUserId); + } + setError({notExist: true}); + setLoading(false); + return; + } + + // Wrong team passed or DM/GM + if (joinedTeam && fetchedChannel.team_id !== '' && fetchedChannel.team_id !== joinedTeam.id) { + removeUserFromTeam(serverUrl, joinedTeam.id, currentUserId); + 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); })(); - }, [channel?.id]); + }, [channelId, teamName]); const handleClose = useCallback(() => { + if (error?.joinedTeam && error.teamId) { + removeUserFromTeam(serverUrl, error.teamId, currentUserId); + } dismissModal({componentId: Screens.PERMALINK}); closePermalink(); - }, []); + }, [error]); useEffect(() => { const listener = BackHandler.addEventListener('hardwareBackPress', () => { @@ -171,6 +272,71 @@ function Permalink({channel, isCRTEnabled, postId}: Props) { } }), []); + const handleJoin = useCallback(preventDoubleTap(async () => { + if (error?.teamId && error.channelId) { + const {error: joinError} = await joinChannel(serverUrl, currentUserId, error.teamId, error.channelId); + if (joinError) { + Alert.alert('Error joining the channel', 'There was an error trying to join the channel'); + return; + } + setLoading(true); + setError(undefined); + setChannelId(error.channelId); + } + }), [error, serverUrl, currentUserId]); + + let content; + if (loading) { + content = ( + + + + ); + } else if (error) { + content = ( + + ); + } else { + content = ( + <> + + + + + + + + + + ); + } + + const showHeaderDivider = Boolean(channel?.displayName) && !error && !loading; return ( - {Boolean(channel?.displayName) && - - } - {loading ? ( - - - - ) : ( - - + {showHeaderDivider && ( + + )} - - - - - + {content} ); diff --git a/app/screens/permalink/permalink_error.tsx b/app/screens/permalink/permalink_error.tsx new file mode 100644 index 000000000..f0eea6939 --- /dev/null +++ b/app/screens/permalink/permalink_error.tsx @@ -0,0 +1,168 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {useIntl} from 'react-intl'; +import {Text, TouchableOpacity, View} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import JoinPrivateChannel from '@components/illustrations/join_private_channel'; +import JoinPublicChannel from '@components/illustrations/join_public_channel'; +import MessageNotViewable from '@components/illustrations/message_not_viewable'; +import Markdown from '@components/markdown'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; +import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + error: PermalinkErrorType; + handleClose: () => void; + handleJoin: () => void; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + errorContainer: { + alignItems: 'center', + justifyContent: 'center', + marginHorizontal: 36, + flex: 1, + }, + errorTitle: { + color: theme.centerChannelColor, + textAlign: 'center', + ...typography('Heading', 600, 'SemiBold'), + marginTop: 8, + marginBottom: 16, + }, + errorText: { + textAlign: 'center', + color: theme.centerChannelColor, + ...typography('Body', 200, 'Regular'), + }, + errorTextParagraph: { + textAlign: 'center', + }, + errorButtonContainer: { + borderTopWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.16), + padding: 20, + }, + + }; +}); + +function PermalinkError({ + error, + handleClose, + handleJoin, +}: Props) { + const theme = useTheme(); + const style = getStyleSheet(theme); + const intl = useIntl(); + + const buttonStylePrimary = buttonBackgroundStyle(theme, 'lg', 'primary'); + const buttonTextStylePrimary = buttonTextStyle(theme, 'lg', 'primary'); + + if (error.notExist || error.unreachable) { + const title = intl.formatMessage({id: 'permalink.error.access.title', defaultMessage: 'Message not viewable'}); + const text = intl.formatMessage({id: 'permalink.error.access.text', defaultMessage: 'The message you are trying to view is in a channel you don’t have access to or has been deleted.'}); + return ( + <> + + + {title} + {text} + + + + + + + + ); + } + + const buttonStyleTertiary = buttonBackgroundStyle(theme, 'lg', 'tertiary'); + const buttonTextStyleTertiary = buttonTextStyle(theme, 'lg', 'tertiary'); + + const isPrivate = error.privateChannel || error.privateTeam; + let image; + let title; + let text; + let button; + if (isPrivate && error.joinedTeam) { + image = (); + title = intl.formatMessage({id: 'permalink.error.private_channel_and_team.title', defaultMessage: 'Join private channel and team'}); + text = intl.formatMessage({id: 'permalink.error.private_channel_and_team.text', defaultMessage: 'The message you are trying to view is in a private channel in a team you are not a member of. You have access as an admin. Do you want to join **{channelName}** and the **{teamName}** team to view it?'}, {channelName: error.channelName, teamName: error.teamName}); + button = intl.formatMessage({id: 'permalink.error.private_channel_and_team.button', defaultMessage: 'Join channel and team'}); + } else if (isPrivate) { + image = (); + title = intl.formatMessage({id: 'permalink.error.private_channel.title', defaultMessage: 'Join private channel'}); + text = intl.formatMessage({id: 'permalink.error.private_channel.text', defaultMessage: 'The message you are trying to view is in a private channel you have not been invited to, but you have access as an admin. Do you still want to join **{channelName}**?'}, {channelName: error.channelName}); + button = intl.formatMessage({id: 'permalink.error.private_channel.button', defaultMessage: 'Join channel'}); + } else if (error.joinedTeam) { + image = (); + title = intl.formatMessage({id: 'permalink.error.public_channel_and_team.title', defaultMessage: 'Join channel and team'}); + text = intl.formatMessage({id: 'permalink.error.public_channel_and_team.text', defaultMessage: 'The message you are trying to view is in a channel you don’t belong and a team you are not a member of. Do you want to join **{channelName}** and the **{teamName}** team to view it?'}, {channelName: error.channelName, teamName: error.teamName}); + button = intl.formatMessage({id: 'permalink.error.public_channel_and_team.button', defaultMessage: 'Join channel and team'}); + } else { + image = (); + title = intl.formatMessage({id: 'permalink.error.public_channel.title', defaultMessage: 'Join channel'}); + text = intl.formatMessage({id: 'permalink.error.public_channel.text', defaultMessage: 'The message you are trying to view is in a channel you don’t belong to. Do you want to join **{channelName}** to view it?'}, {channelName: error.channelName}); + button = intl.formatMessage({id: 'permalink.error.public_channel.button', defaultMessage: 'Join channel'}); + } + return ( + <> + + {image} + {title} + + + + + {button} + + + + + + + ); +} + +export default PermalinkError; diff --git a/app/utils/permalink/index.ts b/app/utils/permalink/index.ts index 4d04d096a..b52f1dba2 100644 --- a/app/utils/permalink/index.ts +++ b/app/utils/permalink/index.ts @@ -4,6 +4,7 @@ import {Keyboard, Platform} from 'react-native'; import {OptionsModalPresentationStyle} from 'react-native-navigation'; +import {Screens} from '@constants'; import {dismissAllModals, showModalOverCurrentContext} from '@screens/navigation'; import {changeOpacity} from '@utils/theme'; @@ -15,7 +16,7 @@ export const displayPermalink = async (teamName: string, postId: string, openAsP await dismissAllModals(); } - const screen = 'Permalink'; + const screen = Screens.PERMALINK; const passProps = { isPermalink: openAsPermalink, teamName, diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 4909276a5..b74e49830 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -598,6 +598,22 @@ "password_send.link.title": "Reset Link Sent", "password_send.reset": "Reset Your Password", "password_send.return": "Return to Log In", + "permalink.error.access.text": "The message you are trying to view is in a channel you don’t have access to or has been deleted.", + "permalink.error.access.title": "Message not viewable", + "permalink.error.cancel": "Cancel", + "permalink.error.okay": "Okay", + "permalink.error.private_channel_and_team.button": "Join channel and team", + "permalink.error.private_channel_and_team.text": "The message you are trying to view is in a private channel in a team you are not a member of. You have access as an admin. Do you want to join **{channelName}** and the **{teamName}** team to view it?", + "permalink.error.private_channel_and_team.title": "Join private channel and team", + "permalink.error.private_channel.button": "Join channel", + "permalink.error.private_channel.text": "The message you are trying to view is in a private channel you have not been invited to, but you have access as an admin. Do you still want to join **{channelName}**?", + "permalink.error.private_channel.title": "Join private channel", + "permalink.error.public_channel_and_team.button": "Join channel and team", + "permalink.error.public_channel_and_team.text": "The message you are trying to view is in a channel you don’t belong and a team you are not a member of. Do you want to join **{channelName}** and the **{teamName}** team to view it?", + "permalink.error.public_channel_and_team.title": "Join channel and team", + "permalink.error.public_channel.button": "Join channel", + "permalink.error.public_channel.text": "The message you are trying to view is in a channel you don’t belong to. Do you want to join **{channelName}** to view it?", + "permalink.error.public_channel.title": "Join channel", "permalink.show_dialog_warn.cancel": "Cancel", "permalink.show_dialog_warn.description": "You are about to join {channel} without explicitly being added by the channel admin. Are you sure you wish to join this private channel?", "permalink.show_dialog_warn.join": "Join", diff --git a/types/components/permalink.d.ts b/types/components/permalink.d.ts new file mode 100644 index 000000000..276bb5ec7 --- /dev/null +++ b/types/components/permalink.d.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type PermalinkErrorType = { + unreachable?: boolean; + notExist?: boolean; + joinedTeam?: boolean; + privateChannel?: boolean; + privateTeam?: boolean; + teamName?: string; + channelName?: string; + teamId?: string; + channelId?: string; +}