From 5178091ab0a5caa15d78c0abcbd6cb20ce78692d Mon Sep 17 00:00:00 2001 From: Kyriakos Z <3829551+koox00@users.noreply.github.com> Date: Mon, 14 Mar 2022 21:41:46 +0200 Subject: [PATCH] MM-40203: permalink modal viewer for mentions (#5999) * Permalink initial commit * Fixes: 10 items per page * MM-40203: permalink modal viewer for mentions Is triggered by pressing on an item in mentions, and shows posts around that item, including the item in a modal. * Adds previously deleted file * address feedback * Move showPermalink as a remote action * address more feedback * fetchPostsAround to only return PostModel * Attempt to autoscroll to highlighted item * Permalink to not highlight saved and pinned posts * Add bottom margin using insets to permalink screen * Use lottie loading indicator * Switch to channel * Missing translation string Co-authored-by: Elias Nahum --- app/actions/remote/command.ts | 2 +- app/actions/{local => remote}/permalink.ts | 32 +-- app/actions/remote/post.ts | 78 +++++- .../markdown/markdown_link/markdown_link.tsx | 2 +- app/components/post_list/index.tsx | 30 ++- app/components/post_list/post/post.tsx | 4 +- app/constants/action_type.ts | 2 + app/constants/general.ts | 1 + app/helpers/api/post.ts | 18 ++ app/helpers/database/index.ts | 9 + app/screens/index.tsx | 11 +- app/screens/navigation.ts | 8 +- app/screens/permalink/index.ts | 36 +++ app/screens/permalink/permalink.tsx | 231 ++++++++++++++++++ app/utils/permalink/index.ts | 36 +++ assets/base/i18n/en.json | 1 + 16 files changed, 456 insertions(+), 45 deletions(-) rename app/actions/{local => remote}/permalink.ts (69%) create mode 100644 app/helpers/api/post.ts create mode 100644 app/helpers/database/index.ts create mode 100644 app/screens/permalink/index.ts create mode 100644 app/screens/permalink/permalink.tsx create mode 100644 app/utils/permalink/index.ts diff --git a/app/actions/remote/command.ts b/app/actions/remote/command.ts index 41e514fea..629bcf6aa 100644 --- a/app/actions/remote/command.ts +++ b/app/actions/remote/command.ts @@ -4,7 +4,7 @@ import {IntlShape} from 'react-intl'; import {Alert} from 'react-native'; -import {showPermalink} from '@actions/local/permalink'; +import {showPermalink} from '@actions/remote/permalink'; import {Client} from '@client/rest'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DeepLinkTypes from '@constants/deep_linking'; diff --git a/app/actions/local/permalink.ts b/app/actions/remote/permalink.ts similarity index 69% rename from app/actions/local/permalink.ts rename to app/actions/remote/permalink.ts index 8a6dbacb4..edbd1ccde 100644 --- a/app/actions/local/permalink.ts +++ b/app/actions/remote/permalink.ts @@ -1,22 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Keyboard} from 'react-native'; - import {fetchMyChannelsForTeam} from '@actions/remote/channel'; import DatabaseManager from '@database/manager'; import {queryCommonSystemValues} from '@queries/servers/system'; import {queryTeamById, queryTeamByName} from '@queries/servers/team'; -import {dismissAllModals, showModalOverCurrentContext} from '@screens/navigation'; import {permalinkBadTeam} from '@utils/draft'; -import {changeOpacity} from '@utils/theme'; +import {displayPermalink} from '@utils/permalink'; import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; import type TeamModel from '@typings/database/models/servers/team'; import type {IntlShape} from 'react-intl'; -let showingPermalink = false; - export const showPermalink = async (serverUrl: string, teamName: string, postId: string, intl: IntlShape, openAsPermalink = true) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { @@ -49,33 +44,10 @@ export const showPermalink = async (serverUrl: string, teamName: string, postId: } } - Keyboard.dismiss(); - if (showingPermalink) { - await dismissAllModals(); - } - - const screen = 'Permalink'; - const passProps = { - isPermalink: openAsPermalink, - teamName, - postId, - }; - - const options = { - layout: { - componentBackgroundColor: changeOpacity('#000', 0.2), - }, - }; - - showingPermalink = true; - showModalOverCurrentContext(screen, passProps, options); + await displayPermalink(team.name, postId, openAsPermalink); return {error: undefined}; } catch (error) { return {error}; } }; - -export const closePermalink = () => { - showingPermalink = false; -}; diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index 30e4234b8..4fcb737ee 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -10,9 +10,11 @@ import {markChannelAsUnread, updateLastPostAt} from '@actions/local/channel'; import {processPostsFetched, removePost} from '@actions/local/post'; import {addRecentReaction} from '@actions/local/reactions'; import {ActionType, Events, General, Post, ServerErrors} from '@constants'; -import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; +import {filterPostsInOrderedArray} from '@helpers/api/post'; import {getNeededAtMentionedUsernames} from '@helpers/api/user'; +import {extractRecordsForTable} from '@helpers/database'; import NetworkManager from '@init/network_manager'; import {prepareMissingChannelsForAllTeams, queryAllMyChannelIds} from '@queries/servers/channel'; import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; @@ -35,6 +37,13 @@ type PostsRequest = { previousPostId?: string; } +type PostsObjectsRequest = { + error?: unknown; + order?: string[]; + posts?: IDMappedObjects; + previousPostId?: string; +} + type AuthorsRequest = { authors?: UserProfile[]; error?: unknown; @@ -430,6 +439,73 @@ export const fetchPostThread = async (serverUrl: string, postId: string, fetchOn } }; +export async function fetchPostsAround(serverUrl: string, channelId: string, postId: string, perPage = General.POST_AROUND_CHUNK_SIZE) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const [after, post, before] = await Promise.all([ + client.getPostsAfter(channelId, postId, 0, perPage), + client.getPostThread(postId), + client.getPostsBefore(channelId, postId, 0, perPage), + ]); + + const preData: PostResponse = { + posts: { + ...filterPostsInOrderedArray(after.posts, after.order), + postId: post.posts![postId], + ...filterPostsInOrderedArray(before.posts, before.order), + }, + order: [], + }; + + const data = await processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_AROUND, preData, true); + + let posts: Model[] = []; + const models: Model[] = []; + if (data.posts?.length) { + try { + const {authors} = await fetchPostAuthors(serverUrl, data.posts, true); + if (authors?.length) { + const userModels = await operator.handleUsers({ + users: authors, + prepareRecordsOnly: true, + }); + models.push(...userModels); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('FETCH AUTHORS ERROR', error); + } + + posts = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_AROUND, + ...data, + prepareRecordsOnly: true, + }); + + models.push(...posts); + await operator.batchRecords(models); + } + + return {posts: extractRecordsForTable(posts, MM_TABLES.SERVER.POST)}; + } catch (error) { + // eslint-disable-next-line no-console + console.error('FETCH POSTS AROUND ERROR', error); + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +} + export const postActionWithCookie = async (serverUrl: string, postId: string, actionId: string, actionCookie: string, selectedOption = '') => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { diff --git a/app/components/markdown/markdown_link/markdown_link.tsx b/app/components/markdown/markdown_link/markdown_link.tsx index abd7e495b..25a4ae74b 100644 --- a/app/components/markdown/markdown_link/markdown_link.tsx +++ b/app/components/markdown/markdown_link/markdown_link.tsx @@ -8,8 +8,8 @@ import {useIntl} from 'react-intl'; import {Alert, StyleSheet, Text, View} from 'react-native'; import urlParse from 'url-parse'; -import {showPermalink} from '@actions/local/permalink'; import {switchToChannelByName} from '@actions/remote/channel'; +import {showPermalink} from '@actions/remote/permalink'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; import DeepLinkTypes from '@constants/deep_linking'; import {useServerUrl} from '@context/server'; diff --git a/app/components/post_list/index.tsx b/app/components/post_list/index.tsx index b72e6e04f..1bdb0355f 100644 --- a/app/components/post_list/index.tsx +++ b/app/components/post_list/index.tsx @@ -29,6 +29,7 @@ type Props = { contentContainerStyle?: StyleProp; currentTimezone: string | null; currentUsername: string; + highlightedId?: PostModel['id']; highlightPinnedOrSaved?: boolean; isTimezoneEnabled: boolean; lastViewedAt: number; @@ -79,6 +80,7 @@ const PostList = ({ currentTimezone, currentUsername, footer, + highlightedId, highlightPinnedOrSaved = true, isTimezoneEnabled, lastViewedAt, @@ -96,6 +98,7 @@ const PostList = ({ const listRef = useRef(null); const onScrollEndIndexListener = useRef(); const onViewableItemsChangedListener = useRef(); + const scrolledToHighlighted = useRef(false); const [offsetY, setOffsetY] = useState(0); const [refreshing, setRefreshing] = useState(false); const theme = useTheme(); @@ -152,11 +155,14 @@ const PostList = ({ const onScrollToIndexFailed = useCallback((info: ScrollIndexFailed) => { const index = Math.min(info.highestMeasuredFrameIndex, info.index); - if (onScrollEndIndexListener.current) { - onScrollEndIndexListener.current(index); + + if (!highlightedId) { + if (onScrollEndIndexListener.current) { + onScrollEndIndexListener.current(index); + } + scrollToIndex(index); } - scrollToIndex(index); - }, []); + }, [highlightedId]); const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => { if (!viewableItems.length) { @@ -286,6 +292,7 @@ const PostList = ({ ); const postProps = { + highlight: highlightedId === item.id, highlightPinnedOrSaved, location, nextPost, @@ -314,6 +321,21 @@ const PostList = ({ }); }, []); + useEffect(() => { + const t = setTimeout(() => { + if (highlightedId && orderedPosts && !scrolledToHighlighted.current) { + scrolledToHighlighted.current = true; + // eslint-disable-next-line max-nested-callbacks + const index = orderedPosts.findIndex((p) => typeof p !== 'string' && p.id === highlightedId); + if (index >= 0) { + scrollToIndex(index, true); + } + } + }, 500); + + return () => clearTimeout(t); + }, [orderedPosts, highlightedId]); + return ( <> , order?: string[]) { + const result: IDMappedObjects = {}; + + if (!posts || !order) { + return result; + } + + for (const id of order) { + if (posts[id]) { + result[id] = posts[id]; + } + } + + return result; +} diff --git a/app/helpers/database/index.ts b/app/helpers/database/index.ts new file mode 100644 index 000000000..c7894f30d --- /dev/null +++ b/app/helpers/database/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type Model from '@nozbe/watermelondb/Model'; + +export const extractRecordsForTable = (records: Model[], tableName: string): T[] => { + // @ts-expect-error constructor.table not exposed in type definition + return records.filter((r) => r.constructor.table === tableName) as T[]; +}; diff --git a/app/screens/index.tsx b/app/screens/index.tsx index f18ef0558..ab684bccb 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -62,6 +62,11 @@ Navigation.setLazyComponentRegistrator((screenName) => { require('@screens/bottom_sheet').default, ); break; + case Screens.BROWSE_CHANNELS: + screen = withServerDatabase( + require('@screens/browse_channels').default, + ); + break; case Screens.CHANNEL: screen = withServerDatabase(require('@screens/channel').default); break; @@ -114,10 +119,8 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.MFA: screen = withIntl(require('@screens/mfa').default); break; - case Screens.BROWSE_CHANNELS: - screen = withServerDatabase( - require('@screens/browse_channels').default, - ); + case Screens.PERMALINK: + screen = withServerDatabase(require('@screens/permalink').default); break; case Screens.POST_OPTIONS: screen = withServerDatabase( diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index e69fccc59..e50dcb377 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -554,13 +554,17 @@ export async function dismissModal(options?: Options & { componentId: string}) { } } -export async function dismissAllModals(options: Options = {}) { +export async function dismissAllModals() { if (!EphemeralStore.hasModalsOpened()) { return; } try { - await Navigation.dismissAllModals(options); + const modals = EphemeralStore.navigationModalStack; + for await (const modal of modals) { + await Navigation.dismissModal(modal, {animations: {dismissModal: {enabled: false}}}); + } + EphemeralStore.clearNavigationModals(); } catch (error) { // RNN returns a promise rejection if there are no modals to diff --git a/app/screens/permalink/index.ts b/app/screens/permalink/index.ts new file mode 100644 index 000000000..d87231ced --- /dev/null +++ b/app/screens/permalink/index.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES} from '@app/constants/database'; +import {WithDatabaseArgs} from '@typings/database/database'; +import PostModel from '@typings/database/models/servers/post'; + +const {POST} = MM_TABLES.SERVER; + +import Permalink from './permalink'; + +type OwnProps = {postId: PostModel['id']} & WithDatabaseArgs; + +const enhance = withObservables([], ({database, postId}: OwnProps) => { + const post = database.get(POST).query( + Q.where('id', postId), + ).observe().pipe( + switchMap((p) => { + return p.length ? p[0].observe() : of$(undefined); + }), + ); + + return { + channel: post.pipe( + switchMap((p) => (p ? p.channel.observe() : of$(undefined))), + ), + }; +}); + +export default withDatabase(enhance(Permalink)); diff --git a/app/screens/permalink/permalink.tsx b/app/screens/permalink/permalink.tsx new file mode 100644 index 000000000..cec3ffb55 --- /dev/null +++ b/app/screens/permalink/permalink.tsx @@ -0,0 +1,231 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {BackHandler, Text, TouchableOpacity, View} from 'react-native'; +import Animated from 'react-native-reanimated'; +import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {switchToChannelById} from '@actions/remote/channel'; +import {fetchPostsAround} from '@actions/remote/post'; +import {Screens} from '@app/constants'; +import {changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import Loading from '@components/loading'; +import PostList from '@components/post_list'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {dismissModal} from '@screens/navigation'; +import ChannelModel from '@typings/database/models/servers/channel'; +import PostModel from '@typings/database/models/servers/post'; +import {closePermalink} from '@utils/permalink'; +import {preventDoubleTap} from '@utils/tap'; + +type Props = { + currentUsername: UserProfile['username']; + postId: PostModel['id']; + channel?: ChannelModel; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flex: 1, + marginTop: 20, + }, + wrapper: { + backgroundColor: theme.centerChannelBg, + borderRadius: 6, + flex: 1, + margin: 10, + opacity: 1, + }, + header: { + alignItems: 'center', + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + flexDirection: 'row', + height: 44, + paddingRight: 16, + width: '100%', + }, + dividerContainer: { + backgroundColor: theme.centerChannelBg, + }, + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + height: 1, + }, + close: { + justifyContent: 'center', + height: 44, + width: 40, + paddingLeft: 7, + }, + titleContainer: { + alignItems: 'center', + flex: 1, + paddingRight: 40, + }, + title: { + color: theme.centerChannelColor, + fontSize: 17, + fontWeight: '600', + }, + postList: { + flex: 1, + }, + loading: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + bottom: { + borderBottomLeftRadius: 6, + borderBottomRightRadius: 6, + }, + footer: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.buttonBg, + flexDirection: 'row', + height: 43, + paddingRight: 16, + width: '100%', + }, + jump: { + color: theme.buttonColor, + fontSize: 15, + 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, postId, currentUsername}: Props) { + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + const theme = useTheme(); + const serverUrl = useServerUrl(); + const insets = useSafeAreaInsets(); + const style = getStyleSheet(theme); + + const containerStyle = useMemo(() => + [style.container, {marginBottom: insets.bottom}], + [style, insets.bottom]); + + useEffect(() => { + (async () => { + if (channel?.id) { + const data = await fetchPostsAround(serverUrl, channel.id, postId, 5); + if (data?.posts) { + setLoading(false); + setPosts(data.posts); + } + } + })(); + }, [channel?.id]); + + const handleClose = useCallback(() => { + dismissModal({componentId: Screens.PERMALINK}); + closePermalink(); + }, []); + + useEffect(() => { + const listener = BackHandler.addEventListener('hardwareBackPress', () => { + handleClose(); + return true; + }); + + return () => { + listener.remove(); + }; + }, []); + + const handlePress = useCallback(preventDoubleTap(() => { + if (channel) { + switchToChannelById(serverUrl, channel?.id, channel?.teamId); + } + }), []); + + return ( + + + + + + + + + {channel?.displayName} + + + + + + + {loading ? ( + + + + ) : ( + + + + )} + + + + + + ); +} + +export default Permalink; diff --git a/app/utils/permalink/index.ts b/app/utils/permalink/index.ts new file mode 100644 index 000000000..70c573ff4 --- /dev/null +++ b/app/utils/permalink/index.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Keyboard} from 'react-native'; + +import {dismissAllModals, showModalOverCurrentContext} from '@screens/navigation'; +import {changeOpacity} from '@utils/theme'; + +let showingPermalink = false; + +export const displayPermalink = async (teamName: string, postId: string, openAsPermalink = true) => { + Keyboard.dismiss(); + if (showingPermalink) { + await dismissAllModals(); + } + + const screen = 'Permalink'; + const passProps = { + isPermalink: openAsPermalink, + teamName, + postId, + }; + + const options = { + layout: { + componentBackgroundColor: changeOpacity('#000', 0.2), + }, + }; + + showingPermalink = true; + showModalOverCurrentContext(screen, passProps, options); +}; + +export const closePermalink = () => { + showingPermalink = false; +}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 778b3bd82..e58136a50 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -337,6 +337,7 @@ "mobile.routes.user_profile": "Profile", "mobile.screen.saved_posts": "Saved Messages", "mobile.screen.your_profile": "Your Profile", + "mobile.search.jump": "Jump to recent messages", "mobile.server_identifier.exists": "You are already connected to this server.", "mobile.server_link.error.text": "The link could not be found on this server.", "mobile.server_link.error.title": "Link Error",