[Gekidou] pinned posts (#6336)

* Pinned messages

* Move isCRTEnabled query to be called earlier and only once

* Update Channel stats when post is (un)pinned

* Create svg module type definition

* Add missing localization strings

* feedback review
This commit is contained in:
Elias Nahum
2022-06-03 09:27:45 -04:00
committed by GitHub
parent 6d0e65d5fd
commit a61d65eb09
21 changed files with 396 additions and 10 deletions

View File

@@ -1044,3 +1044,85 @@ export async function fetchSavedPosts(serverUrl: string, teamId?: string, channe
return {error};
}
}
export async function fetchPinnedPosts(serverUrl: string, channelId: string) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const data = await client.getPinnedPosts(channelId);
const posts = data.posts || {};
const order = data.order || [];
const postsArray = order.map((id) => posts[id]);
if (!postsArray.length) {
return {
order,
posts: postsArray,
};
}
const promises: Array<Promise<Model[]>> = [];
const {database} = operator;
const isCRTEnabled = await getIsCRTEnabled(database);
const {authors} = await fetchPostAuthors(serverUrl, postsArray, true);
const {channels, channelMemberships} = await fetchMissingChannelsFromPosts(serverUrl, postsArray, true);
if (authors?.length) {
promises.push(
operator.handleUsers({
users: authors,
prepareRecordsOnly: true,
}),
);
}
if (channels?.length && channelMemberships?.length) {
const channelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships, isCRTEnabled);
if (channelPromises.length) {
promises.push(...channelPromises);
}
}
promises.push(
operator.handlePosts({
actionType: '',
order: [],
posts: postsArray,
previousPostId: '',
prepareRecordsOnly: true,
}),
);
if (isCRTEnabled) {
promises.push(prepareThreadsFromReceivedPosts(operator, postsArray));
}
const modelArrays = await Promise.all(promises);
const models = modelArrays.flatMap((mdls) => {
if (!mdls || !mdls.length) {
return [];
}
return mdls;
});
await operator.batchRecords(models);
return {
order,
posts: postsArray,
};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
}

View File

@@ -7,7 +7,7 @@ import {DeviceEventEmitter} from 'react-native';
import {storeMyChannelsForTeam, markChannelAsUnread, markChannelAsViewed, updateLastPostAt} from '@actions/local/channel';
import {markPostAsDeleted} from '@actions/local/post';
import {createThreadFromNewPost, updateThread} from '@actions/local/thread';
import {fetchMyChannel, markChannelAsRead} from '@actions/remote/channel';
import {fetchChannelStats, fetchMyChannel, markChannelAsRead} from '@actions/remote/channel';
import {fetchPostAuthors, fetchPostById} from '@actions/remote/post';
import {fetchThread} from '@actions/remote/thread';
import {ActionType, Events, Screens} from '@constants';
@@ -196,6 +196,12 @@ export async function handlePostEdited(serverUrl: string, msg: WebSocketMessage)
}
const models: Model[] = [];
const {database} = operator;
const oldPost = await getPostById(database, post.id);
if (oldPost && oldPost.isPinned !== post.is_pinned) {
fetchChannelStats(serverUrl, post.channel_id);
}
const {authors} = await fetchPostAuthors(serverUrl, [post], true);
if (authors?.length) {

View File

@@ -18,7 +18,7 @@ export interface ClientPostsMix {
getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise<PostResponse>;
getFileInfosForPost: (postId: string) => Promise<FileInfo[]>;
getSavedPosts: (userId: string, channelId?: string, teamId?: string, page?: number, perPage?: number) => Promise<PostResponse>;
getPinnedPosts: (channelId: string) => Promise<any>;
getPinnedPosts: (channelId: string) => Promise<PostResponse>;
markPostAsUnread: (userId: string, postId: string) => Promise<any>;
pinPost: (postId: string) => Promise<any>;
unpinPost: (postId: string) => Promise<any>;

View File

@@ -37,6 +37,7 @@ const InfoBox = ({channelId, containerStyle, showAsLabel = false, testID}: Props
testID: closeButtonId,
}],
},
modal: {swipeToDismiss: false},
};
showModal(Screens.CHANNEL_INFO, title, {channelId, closeButtonId}, options);
}, [intl, channelId, theme]);

View File

@@ -150,7 +150,7 @@ const OptionItem = ({
</View>
</View>
</View>
{Boolean(actionComponent) &&
{Boolean(actionComponent || info) &&
<View style={styles.actionContainer}>
{Boolean(info) &&
<View style={styles.infoContainer}>

View File

@@ -13,7 +13,6 @@ import {calculateDimensions, getViewPortWidth} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {getYouTubeVideoId, tryOpenURL} from '@utils/url';
// @ts-expect-error import svg
import YouTubeLogo from './youtube.svg';
type YouTubeProps = {

View File

@@ -133,7 +133,7 @@ const Post = ({
}, [isConsecutivePost, post, previousPost, isFirstReply]);
const handlePostPress = () => {
if ([Screens.SAVED_POSTS, Screens.MENTIONS, Screens.SEARCH].includes(location)) {
if ([Screens.SAVED_POSTS, Screens.MENTIONS, Screens.SEARCH, Screens.PINNED_MESSAGES].includes(location)) {
showPermalink(serverUrl, '', post.id, intl);
return;
}

View File

@@ -131,6 +131,5 @@ export const NOT_READY = [
CREATE_TEAM,
INTEGRATION_SELECTOR,
INTERACTIVE_DIALOG,
PINNED_MESSAGES,
USER_PROFILE,
];

View File

@@ -178,3 +178,16 @@ export const queryPostsBetween = (database: Database, earliest: number, latest:
}
return database.get<PostModel>(POST).query(...clauses);
};
export const queryPinnedPostsInChannel = (database: Database, channelId: string) => {
return database.get<PostModel>(POST).query(
Q.and(
Q.where('channel_id', channelId),
Q.where('is_pinned', Q.eq(true)),
),
);
};
export const observePinnedPostsInChannel = (database: Database, channelId: string) => {
return queryPinnedPostsInChannel(database, channelId).observe();
};

View File

@@ -114,6 +114,7 @@ const ChannelHeader = ({
testID: closeButtonId,
}],
},
modal: {swipeToDismiss: false},
};
showModal(Screens.CHANNEL_INFO, title, {channelId, closeButtonId}, options);
}), [channelId, channelType, intl, theme]);

View File

@@ -21,7 +21,6 @@ import {isEmail} from '@utils/helpers';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
// @ts-expect-error svg extension
import Inbox from './inbox.svg';
type Props = {

View File

@@ -9,7 +9,6 @@ import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
// @ts-expect-error svg extension
import SearchHintSVG from './illustrations/search_hint.svg';
const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({

View File

@@ -150,6 +150,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.PERMALINK:
screen = withServerDatabase(require('@screens/permalink').default);
break;
case Screens.PINNED_MESSAGES:
screen = withServerDatabase(require('@screens/pinned_messages').default);
break;
case Screens.POST_OPTIONS:
screen = withServerDatabase(
require('@screens/post_options').default,

View File

@@ -24,7 +24,6 @@ import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
// @ts-expect-error svg extension
import Shield from './mfa.svg';
type MFAProps = {

View File

@@ -0,0 +1,9 @@
<svg width="140" height="141" viewBox="0 0 140 141" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M55.8798 90.1401L55.6921 90.3277L54.9625 91.2451C53.9816 92.3473 52.9374 93.3915 51.8354 94.3725L49.8132 96.2177L39.525 105.225L27.3917 114.961C27.2644 115.052 27.1205 115.116 26.9683 115.151C26.8162 115.186 26.6586 115.19 26.5047 115.164C26.3508 115.138 26.2035 115.082 26.0713 114.999C25.9391 114.916 25.8245 114.807 25.7342 114.68C25.5037 114.457 25.3595 114.161 25.327 113.842C25.2946 113.523 25.3761 113.203 25.5571 112.939L29.2263 108.342L42.0892 93.0903L46.1336 88.493C47.3636 87.2698 48.3434 86.2899 49.0731 85.5533L50.3656 84.4482C51.1885 83.8489 52.1957 83.5575 53.2114 83.6247C53.7075 83.6459 54.1935 83.7721 54.6373 83.9949C55.0811 84.2177 55.4726 84.5321 55.786 84.9173C56.4338 85.6169 56.8044 86.5287 56.8284 87.4818C56.8009 88.4459 56.4688 89.3764 55.8798 90.1401V90.1401Z" fill="#A4A9B7"/>
<path d="M36.6804 102.268L48.8138 87.6328L50.9323 88.7882L36.6804 102.268Z" fill="#E8E9ED"/>
<path d="M114.68 58.6262C114.69 59.1216 114.596 59.6136 114.406 60.0711C114.216 60.5286 113.933 60.9418 113.575 61.2845L112.293 62.5771C110.383 64.4609 107.809 65.5171 105.127 65.5171C102.444 65.5171 99.8699 64.4609 97.9603 62.5771C97.9177 62.519 97.862 62.4717 97.7977 62.4391C97.7334 62.4065 97.6624 62.3894 97.5903 62.3894C97.5182 62.3894 97.4472 62.4065 97.3829 62.4391C97.3186 62.4717 97.2629 62.519 97.2203 62.5771L81.9703 77.6409C81.5019 78.1292 81.2251 78.77 81.1907 79.4458C81.1562 80.1217 81.3663 80.7872 81.7826 81.3207C83.5802 83.9612 84.3954 87.1483 84.0863 90.3277C83.7742 93.5145 82.3799 96.4983 80.1357 98.7821L74.2567 104.662C72.7177 106.191 70.636 107.05 68.4662 107.05C66.2964 107.05 64.2148 106.191 62.6759 104.662L35.6574 77.8285C34.1278 76.2894 33.2693 74.2076 33.2693 72.0376C33.2693 69.8676 34.1278 67.7857 35.6574 66.2467L41.7241 60.3671C43.9488 58.0251 46.9584 56.5851 50.1778 56.3223C53.363 56.0805 56.5366 56.9218 59.1839 58.7096C59.7146 59.0731 60.3572 59.236 60.9969 59.1691C61.6366 59.1023 62.2318 58.81 62.6759 58.3447L77.905 43.3019C77.9661 43.1881 77.9981 43.061 77.9981 42.9318C77.9981 42.8026 77.9661 42.6755 77.905 42.5617C76.0213 40.6519 74.9653 38.0772 74.9653 35.3947C74.9653 32.7122 76.0213 30.1375 77.905 28.2277L79.01 26.9455C79.7886 26.2368 80.8028 25.8429 81.8556 25.8405C82.3524 25.8321 82.8455 25.9258 83.3046 26.1159C83.7636 26.306 84.1786 26.5884 84.5241 26.9455L113.554 55.9783C113.914 56.3188 114.199 56.7296 114.393 57.1851C114.586 57.6406 114.684 58.1312 114.68 58.6262Z" fill="#FFBC1F"/>
<path d="M69.0357 71.8425C62.7186 65.5264 61.011 61.1231 60.9468 59.7109C52.4728 56.0522 51.8948 57.5927 55.9394 62.2143C60.0548 66.9169 76.932 79.7377 69.0357 71.8425Z" fill="#FFD470"/>
<path d="M71.1295 66.0693C70.8433 66.3651 70.4996 66.599 70.1196 66.7568C69.7395 66.9146 69.3311 66.9928 68.9197 66.9866C68.5096 66.9939 68.1025 66.9161 67.724 66.7582C67.3454 66.6003 67.0037 66.3658 66.7203 66.0693C66.435 65.8014 66.2067 65.4786 66.0491 65.1204C65.8914 64.7621 65.8077 64.3758 65.803 63.9844C65.8242 63.1316 66.1495 62.3144 66.7203 61.6805L78.8431 49.7442C79.265 49.3329 79.7976 49.0534 80.3758 48.9398C80.9539 48.8262 81.5525 48.8835 82.0986 49.1046C82.6448 49.3257 83.1147 49.7012 83.451 50.185C83.7873 50.6688 83.9754 51.2401 83.9924 51.8291C83.9865 52.2203 83.9023 52.6064 83.7448 52.9644C83.5872 53.3225 83.3595 53.6454 83.0751 53.9141L71.1295 66.0485V66.0693Z" fill="#FFD470"/>
<path d="M95.2103 60.5901L93.3436 58.6854C82.7457 48.1638 76.082 46.6005 73.9995 47.2033L78.1721 43.2046L93.3436 58.6854C93.9529 59.2903 94.5752 59.9247 95.2103 60.5901Z" fill="#F5AB00"/>
<ellipse cx="59" cy="115" rx="34" ry="2.5" fill="black" fill-opacity="0.06"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import EmptyIllustration from './empty.svg';
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 40,
},
title: {
color: theme.centerChannelColor,
...typography('Heading', 400, 'SemiBold'),
},
paragraph: {
marginTop: 8,
textAlign: 'center',
color: changeOpacity(theme.centerChannelColor, 0.72),
...typography('Body', 200),
},
}));
function EmptySavedPosts() {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View style={styles.container}>
<EmptyIllustration/>
<FormattedText
defaultMessage='No pinned messages yet'
id='pinned_messages.empty.title'
style={styles.title}
testID='pinned_messages.empty.title'
/>
<FormattedText
defaultMessage={'To pin important messages, long-press on a message and chose Pin To Channel. Pinned messages will be visible to everyone in this channel.'}
id='pinned_messages.empty.paragraph'
style={styles.paragraph}
testID='pinned_messages.empty.paragraph'
/>
</View>
);
}
export default EmptySavedPosts;

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observePinnedPostsInChannel} from '@queries/servers/post';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeIsCRTEnabled} from '@queries/servers/thread';
import {observeCurrentUser} from '@queries/servers/user';
import {getTimezone} from '@utils/user';
import PinnedMessages from './pinned_messages';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
}
const enhance = withObservables(['channelId'], ({channelId, database}: Props) => {
const currentUser = observeCurrentUser(database);
const posts = observePinnedPostsInChannel(database, channelId);
return {
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))),
isCRTEnabled: observeIsCRTEnabled(database),
isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'),
posts,
};
});
export default withDatabase(enhance(PinnedMessages));

View File

@@ -0,0 +1,177 @@
// 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, DeviceEventEmitter, FlatList, StyleSheet, View} from 'react-native';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {fetchPinnedPosts} from '@actions/remote/post';
import Loading from '@components/loading';
import DateSeparator from '@components/post_list/date_separator';
import Post from '@components/post_list/post';
import {Events, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {popTopScreen} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {isDateLine, getDateForDateLine, selectOrderedPosts} from '@utils/post_list';
import EmptyState from './empty';
import type {ViewableItemsChanged} from '@typings/components/post_list';
import type PostModel from '@typings/database/models/servers/post';
type Props = {
channelId: string;
componentId?: string;
currentTimezone: string | null;
isCRTEnabled: boolean;
isTimezoneEnabled: boolean;
posts: PostModel[];
}
const edges: Edge[] = ['bottom', 'left', 'right'];
const styles = StyleSheet.create({
flex: {
flex: 1,
},
empty: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
list: {
paddingVertical: 8,
},
loading: {
height: 40,
width: 40,
justifyContent: 'center' as const,
},
});
function SavedMessages({
channelId,
componentId,
currentTimezone,
isCRTEnabled,
isTimezoneEnabled,
posts,
}: Props) {
const [loading, setLoading] = useState(!posts.length);
const [refreshing, setRefreshing] = useState(false);
const theme = useTheme();
const serverUrl = useServerUrl();
const data = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, isTimezoneEnabled, currentTimezone, false).reverse(), [posts]);
const close = () => {
if (componentId) {
popTopScreen(componentId);
}
};
useEffect(() => {
fetchPinnedPosts(serverUrl, channelId).finally(() => {
setLoading(false);
});
}, []);
useEffect(() => {
const listener = BackHandler.addEventListener('hardwareBackPress', () => {
if (EphemeralStore.getNavigationTopComponentId() === componentId) {
close();
return true;
}
return false;
});
return () => listener.remove();
}, [componentId]);
const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => {
if (!viewableItems.length) {
return;
}
const viewableItemsMap = viewableItems.reduce((acc: Record<string, boolean>, {item, isViewable}) => {
if (isViewable) {
acc[`${Screens.PINNED_MESSAGES}-${item.id}`] = true;
}
return acc;
}, {});
DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, viewableItemsMap);
}, []);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await fetchPinnedPosts(serverUrl, channelId);
setRefreshing(false);
}, [serverUrl, channelId]);
const emptyList = useMemo(() => (
<View style={styles.empty}>
{loading ? (
<Loading
color={theme.buttonBg}
size='large'
/>
) : (
<EmptyState/>
)}
</View>
), [loading, theme.buttonBg]);
const renderItem = useCallback(({item}) => {
if (typeof item === 'string') {
if (isDateLine(item)) {
return (
<DateSeparator
date={getDateForDateLine(item)}
theme={theme}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
}
return null;
}
return (
<Post
highlightPinnedOrSaved={false}
isCRTEnabled={isCRTEnabled}
location={Screens.PINNED_MESSAGES}
nextPost={undefined}
post={item}
previousPost={undefined}
showAddReaction={false}
shouldRenderReplyButton={false}
skipSavedHeader={true}
skipPinnedHeader={true}
/>
);
}, [currentTimezone, isTimezoneEnabled, theme]);
return (
<SafeAreaView
edges={edges}
style={styles.flex}
>
<FlatList
contentContainerStyle={data.length ? styles.list : [styles.empty]}
ListEmptyComponent={emptyList}
data={data}
onRefresh={handleRefresh}
refreshing={refreshing}
renderItem={renderItem}
scrollToOverflowEnabled={true}
onViewableItemsChanged={onViewableItemsChanged}
/>
</SafeAreaView>
);
}
export default SavedMessages;

View File

@@ -149,7 +149,7 @@ function SavedMessages({
<EmptyState/>
)}
</View>
), [loading, theme.centerChannelColor]);
), [loading, theme.buttonBg]);
const renderItem = useCallback(({item}) => {
if (typeof item === 'string') {

View File

@@ -597,6 +597,8 @@
"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",
"permalink.show_dialog_warn.title": "Join private channel",
"pinned_messages.empty.paragraph": "To pin important messages, long-press on a message and chose Pin To Channel. Pinned messages will be visible to everyone in this channel.",
"pinned_messages.empty.title": "No pinned messages yet",
"plus_menu.browse_channels.title": "Browse Channels",
"plus_menu.create_new_channel.title": "Create New Channel",
"plus_menu.open_direct_message.title": "Open a Direct Message",

7
types/global/svg.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
declare module '*.svg' {
const content: any;
export default content;
}