From a61d65eb093c40b54193f50d24e64a6ce93e8447 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Fri, 3 Jun 2022 09:27:45 -0400 Subject: [PATCH] [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 --- app/actions/remote/post.ts | 82 ++++++++ app/actions/websocket/posts.ts | 8 +- app/client/rest/posts.ts | 2 +- .../channel_actions/info_box/index.tsx | 1 + app/components/option_item/index.tsx | 2 +- .../post/body/content/youtube/index.tsx | 1 - app/components/post_list/post/post.tsx | 2 +- app/constants/screens.ts | 1 - app/queries/servers/post.ts | 13 ++ app/screens/channel/header/header.tsx | 1 + app/screens/forgot_password/index.tsx | 1 - .../threads_list/end_of_list.tsx | 1 - app/screens/index.tsx | 3 + app/screens/mfa/index.tsx | 1 - app/screens/pinned_messages/empty/empty.svg | 9 + app/screens/pinned_messages/empty/index.tsx | 55 ++++++ app/screens/pinned_messages/index.ts | 35 ++++ .../pinned_messages/pinned_messages.tsx | 177 ++++++++++++++++++ app/screens/saved_posts/saved_posts.tsx | 2 +- assets/base/i18n/en.json | 2 + types/global/svg.d.ts | 7 + 21 files changed, 396 insertions(+), 10 deletions(-) create mode 100644 app/screens/pinned_messages/empty/empty.svg create mode 100644 app/screens/pinned_messages/empty/index.tsx create mode 100644 app/screens/pinned_messages/index.ts create mode 100644 app/screens/pinned_messages/pinned_messages.tsx create mode 100644 types/global/svg.d.ts diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index e091db6754..42d56635f2 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -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> = []; + 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}; + } +} diff --git a/app/actions/websocket/posts.ts b/app/actions/websocket/posts.ts index 1196b19370..b073b44953 100644 --- a/app/actions/websocket/posts.ts +++ b/app/actions/websocket/posts.ts @@ -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) { diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index 92e4ecc81f..1cd1b51e5e 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -18,7 +18,7 @@ export interface ClientPostsMix { getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; getFileInfosForPost: (postId: string) => Promise; getSavedPosts: (userId: string, channelId?: string, teamId?: string, page?: number, perPage?: number) => Promise; - getPinnedPosts: (channelId: string) => Promise; + getPinnedPosts: (channelId: string) => Promise; markPostAsUnread: (userId: string, postId: string) => Promise; pinPost: (postId: string) => Promise; unpinPost: (postId: string) => Promise; diff --git a/app/components/channel_actions/info_box/index.tsx b/app/components/channel_actions/info_box/index.tsx index 4eb377fc1e..347e844702 100644 --- a/app/components/channel_actions/info_box/index.tsx +++ b/app/components/channel_actions/info_box/index.tsx @@ -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]); diff --git a/app/components/option_item/index.tsx b/app/components/option_item/index.tsx index 5102575168..956744c327 100644 --- a/app/components/option_item/index.tsx +++ b/app/components/option_item/index.tsx @@ -150,7 +150,7 @@ const OptionItem = ({ - {Boolean(actionComponent) && + {Boolean(actionComponent || info) && {Boolean(info) && diff --git a/app/components/post_list/post/body/content/youtube/index.tsx b/app/components/post_list/post/body/content/youtube/index.tsx index f99ee3e904..34b5103b18 100644 --- a/app/components/post_list/post/body/content/youtube/index.tsx +++ b/app/components/post_list/post/body/content/youtube/index.tsx @@ -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 = { diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index 9dae96f15c..bfd2f1ed69 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -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; } diff --git a/app/constants/screens.ts b/app/constants/screens.ts index fe5f670345..6aefc0ecdc 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -131,6 +131,5 @@ export const NOT_READY = [ CREATE_TEAM, INTEGRATION_SELECTOR, INTERACTIVE_DIALOG, - PINNED_MESSAGES, USER_PROFILE, ]; diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index b2ba8c0462..a2c2054126 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -178,3 +178,16 @@ export const queryPostsBetween = (database: Database, earliest: number, latest: } return database.get(POST).query(...clauses); }; + +export const queryPinnedPostsInChannel = (database: Database, channelId: string) => { + return database.get(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(); +}; diff --git a/app/screens/channel/header/header.tsx b/app/screens/channel/header/header.tsx index 6d9f28979b..4e3375a2b9 100644 --- a/app/screens/channel/header/header.tsx +++ b/app/screens/channel/header/header.tsx @@ -114,6 +114,7 @@ const ChannelHeader = ({ testID: closeButtonId, }], }, + modal: {swipeToDismiss: false}, }; showModal(Screens.CHANNEL_INFO, title, {channelId, closeButtonId}, options); }), [channelId, channelType, intl, theme]); diff --git a/app/screens/forgot_password/index.tsx b/app/screens/forgot_password/index.tsx index cd88468781..ec3358c70c 100644 --- a/app/screens/forgot_password/index.tsx +++ b/app/screens/forgot_password/index.tsx @@ -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 = { diff --git a/app/screens/global_threads/threads_list/end_of_list.tsx b/app/screens/global_threads/threads_list/end_of_list.tsx index fb077306ea..d200dced96 100644 --- a/app/screens/global_threads/threads_list/end_of_list.tsx +++ b/app/screens/global_threads/threads_list/end_of_list.tsx @@ -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) => ({ diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 7dc16f98a3..de83667840 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -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, diff --git a/app/screens/mfa/index.tsx b/app/screens/mfa/index.tsx index a37a6f8370..7eb4871385 100644 --- a/app/screens/mfa/index.tsx +++ b/app/screens/mfa/index.tsx @@ -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 = { diff --git a/app/screens/pinned_messages/empty/empty.svg b/app/screens/pinned_messages/empty/empty.svg new file mode 100644 index 0000000000..970efe0a31 --- /dev/null +++ b/app/screens/pinned_messages/empty/empty.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/screens/pinned_messages/empty/index.tsx b/app/screens/pinned_messages/empty/index.tsx new file mode 100644 index 0000000000..bdbbde360d --- /dev/null +++ b/app/screens/pinned_messages/empty/index.tsx @@ -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 ( + + + + + + ); +} + +export default EmptySavedPosts; diff --git a/app/screens/pinned_messages/index.ts b/app/screens/pinned_messages/index.ts new file mode 100644 index 0000000000..8c9968c48a --- /dev/null +++ b/app/screens/pinned_messages/index.ts @@ -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)); diff --git a/app/screens/pinned_messages/pinned_messages.tsx b/app/screens/pinned_messages/pinned_messages.tsx new file mode 100644 index 0000000000..1932bd0b20 --- /dev/null +++ b/app/screens/pinned_messages/pinned_messages.tsx @@ -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, {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(() => ( + + {loading ? ( + + ) : ( + + )} + + ), [loading, theme.buttonBg]); + + const renderItem = useCallback(({item}) => { + if (typeof item === 'string') { + if (isDateLine(item)) { + return ( + + ); + } + return null; + } + + return ( + + ); + }, [currentTimezone, isTimezoneEnabled, theme]); + + return ( + + + + ); +} + +export default SavedMessages; diff --git a/app/screens/saved_posts/saved_posts.tsx b/app/screens/saved_posts/saved_posts.tsx index 3ba20e5da4..b33e051381 100644 --- a/app/screens/saved_posts/saved_posts.tsx +++ b/app/screens/saved_posts/saved_posts.tsx @@ -149,7 +149,7 @@ function SavedMessages({ )} - ), [loading, theme.centerChannelColor]); + ), [loading, theme.buttonBg]); const renderItem = useCallback(({item}) => { if (typeof item === 'string') { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index d6dd692f4f..97289fd352 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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", diff --git a/types/global/svg.d.ts b/types/global/svg.d.ts new file mode 100644 index 0000000000..1754b0ee1e --- /dev/null +++ b/types/global/svg.d.ts @@ -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; +}