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 <nahumhbl@gmail.com>
This commit is contained in:
Kyriakos Z 2022-03-14 21:41:46 +02:00 committed by GitHub
parent a43dad53e1
commit 5178091ab0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 456 additions and 45 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@ type Props = {
contentContainerStyle?: StyleProp<ViewStyle>;
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<FlatList>(null);
const onScrollEndIndexListener = useRef<onScrollEndIndexListenerEvent>();
const onViewableItemsChangedListener = useRef<ViewableItemsChangedListenerEvent>();
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 (
<>
<PostListRefreshControl

View file

@ -5,8 +5,8 @@ import React, {ReactNode, useMemo, useRef} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, StyleProp, View, ViewStyle, TouchableHighlight} from 'react-native';
import {showPermalink} from '@actions/local/permalink';
import {removePost} from '@actions/local/post';
import {showPermalink} from '@actions/remote/permalink';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import SystemAvatar from '@components/system_avatar';
import SystemHeader from '@components/system_header';
@ -129,7 +129,7 @@ const Post = ({
if (post) {
if (location === Screens.THREAD) {
Keyboard.dismiss();
} else if (location === Screens.SEARCH) {
} else if ([Screens.SAVED_POSTS, Screens.MENTIONS, Screens.SEARCH].includes(location)) {
showPermalink(serverUrl, '', post.id, intl);
return;
}

View file

@ -9,7 +9,9 @@ export const POSTS = keyMirror({
RECEIVED_SINCE: null,
RECEIVED_AFTER: null,
RECEIVED_BEFORE: null,
RECEIVED_AROUND: null,
RECEIVED_NEW: null,
RECEIVED_POST_THREAD: null,
});
export default {

View file

@ -4,6 +4,7 @@
export default {
PAGE_SIZE_DEFAULT: 60,
POST_CHUNK_SIZE: 60,
POST_AROUND_CHUNK_SIZE: 10,
CHANNELS_CHUNK_SIZE: 50,
STATUS_INTERVAL: 60000,
AUTOCOMPLETE_LIMIT_DEFAULT: 25,

18
app/helpers/api/post.ts Normal file
View file

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function filterPostsInOrderedArray(posts?: IDMappedObjects<Post>, order?: string[]) {
const result: IDMappedObjects<Post> = {};
if (!posts || !order) {
return result;
}
for (const id of order) {
if (posts[id]) {
result[id] = posts[id];
}
}
return result;
}

View file

@ -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 = <T>(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[];
};

View file

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

View file

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

View file

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

View file

@ -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<PostModel[]>([]);
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 (
<SafeAreaView style={containerStyle}>
<Animated.View style={style.wrapper}>
<View style={style.header}>
<TouchableOpacity
style={style.close}
onPress={handleClose}
>
<CompassIcon
name='close'
size={20}
color={theme.centerChannelColor}
/>
</TouchableOpacity>
<View style={style.titleContainer}>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={style.title}
>
{channel?.displayName}
</Text>
</View>
</View>
<View style={style.dividerContainer}>
<View style={style.divider}/>
</View>
{loading ? (
<View style={style.loading}>
<Loading
color={theme.buttonBg}
/>
</View>
) : (
<View style={style.postList}>
<PostList
highlightedId={postId}
posts={posts}
location={Screens.PERMALINK}
lastViewedAt={0}
isTimezoneEnabled={false}
shouldShowJoinLeaveMessages={false}
currentTimezone={null}
currentUsername={currentUsername}
channelId={channel!.id}
testID='permalink.post_list'
nativeID={Screens.PERMALINK}
highlightPinnedOrSaved={false}
/>
</View>
)}
<TouchableOpacity
style={[style.footer, style.bottom]}
onPress={handlePress}
>
<FormattedText
testID='permalink.search.jump'
id='mobile.search.jump'
defaultMessage='Jump to recent messages'
style={style.jump}
/>
</TouchableOpacity>
</Animated.View>
</SafeAreaView>
);
}
export default Permalink;

View file

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

View file

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