diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index f99f1cd9a..5291850a4 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -2,6 +2,8 @@ // See LICENSE.txt for license information. // +/* eslint-disable max-lines */ + import {DeviceEventEmitter} from 'react-native'; import {markChannelAsUnread, updateLastPostAt} from '@actions/local/channel'; @@ -459,7 +461,7 @@ export const postActionWithCookie = async (serverUrl: string, postId: string, ac } }; -export async function getMissingChannelsFromPosts(serverUrl: string, posts: Post[], fetchOnly = false) { +export async function fetchMissingChannelsFromPosts(serverUrl: string, posts: Post[], fetchOnly = false) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; @@ -472,45 +474,50 @@ export async function getMissingChannelsFromPosts(serverUrl: string, posts: Post return {error}; } - const channelIds = await queryAllMyChannelIds(operator.database); - const channelPromises: Array> = []; - const userPromises: Array> = []; + try { + const channelIds = await queryAllMyChannelIds(operator.database); + const channelPromises: Array> = []; + const userPromises: Array> = []; - posts.forEach((post) => { - const id = post.channel_id; + posts.forEach((post) => { + const id = post.channel_id; - if (channelIds.indexOf(id) === -1) { - channelPromises.push(client.getChannel(id)); - userPromises.push(client.getMyChannelMember(id)); - } - }); + if (channelIds.indexOf(id) === -1) { + channelPromises.push(client.getChannel(id)); + userPromises.push(client.getMyChannelMember(id)); + } + }); - const channels = await Promise.all(channelPromises); - const channelMemberships = await Promise.all(userPromises); + const channels = await Promise.all(channelPromises); + const channelMemberships = await Promise.all(userPromises); - if (!fetchOnly && channels.length && channelMemberships.length) { - const modelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships) as Array>; - if (modelPromises && modelPromises.length) { - const channelModelsArray = await Promise.all(modelPromises); - if (channelModelsArray.length) { - const models = channelModelsArray.flatMap((mdls) => { - if (!mdls || mdls.length) { - return []; + if (!fetchOnly && channels.length && channelMemberships.length) { + const modelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships) as Array>; + if (modelPromises && modelPromises.length) { + const channelModelsArray = await Promise.all(modelPromises); + if (channelModelsArray.length) { + const models = channelModelsArray.flatMap((mdls) => { + if (!mdls || mdls.length) { + return []; + } + return mdls; + }); + + if (models) { + operator.batchRecords(models); } - return mdls; - }); - - if (models) { - operator.batchRecords(models); } } } - } - return { - channels, - channelMemberships, - }; + return { + channels, + channelMemberships, + }; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } } export const fetchPostById = async (serverUrl: string, postId: string, fetchOnly = false) => { @@ -529,7 +536,24 @@ export const fetchPostById = async (serverUrl: string, postId: string, fetchOnly try { const post = await client.getPost(postId); if (!fetchOnly) { - operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_NEW, order: [post.id], posts: [post]}); + const models: Model[] = []; + const {authors} = await fetchPostAuthors(serverUrl, [post], true); + const posts = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [post.id], + posts: [post], + prepareRecordsOnly: true, + }); + models.push(...posts); + if (authors?.length) { + const users = await operator.handleUsers({ + users: authors, + prepareRecordsOnly: false, + }); + models.push(...users); + } + + await operator.batchRecords(models); } return {post}; @@ -641,3 +665,82 @@ export const markPostAsUnread = async (serverUrl: string, postId: string) => { return {error}; } }; + +export async function fetchSavedPosts(serverUrl: string, teamId?: string, channelId?: string, page?: number, perPage?: number) { + 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 userId = await queryCurrentUserId(operator.database); + const data = await client.getSavedPosts(userId, channelId, teamId, page, perPage); + 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 {authors} = await fetchPostAuthors(serverUrl, postsArray, true); + const {channels, channelMemberships} = await fetchMissingChannelsFromPosts(serverUrl, postsArray, true) as {channels: Channel[]; channelMemberships: ChannelMembership[]}; + + if (authors?.length) { + promises.push( + operator.handleUsers({ + users: authors, + prepareRecordsOnly: true, + }), + ); + } + + if (channels?.length && channelMemberships?.length) { + const channelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships) as Array>; + if (channelPromises && channelPromises.length) { + promises.push(...channelPromises); + } + } + + promises.push( + operator.handlePosts({ + actionType: '', + order: [], + posts: postsArray, + previousPostId: '', + prepareRecordsOnly: true, + }), + ); + + const modelArrays = await Promise.all(promises); + const models = modelArrays.flatMap((mdls) => { + if (!mdls || !mdls.length) { + return []; + } + return mdls; + }); + + if (models.length) { + await operator.batchRecords(models); + } + + return { + order, + posts: postsArray, + }; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +} diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts index fc725b7f0..16bc9f695 100644 --- a/app/actions/remote/search.ts +++ b/app/actions/remote/search.ts @@ -8,7 +8,7 @@ import NetworkManager from '@init/network_manager'; import {prepareMissingChannelsForAllTeams} from '@queries/servers/channel'; import {queryCurrentUser} from '@queries/servers/user'; -import {fetchPostAuthors, getMissingChannelsFromPosts} from './post'; +import {fetchPostAuthors, fetchMissingChannelsFromPosts} from './post'; import {forceLogoutIfNecessary} from './session'; import type {Client} from '@client/rest'; @@ -20,7 +20,7 @@ type PostSearchRequest = { posts?: Post[]; } -export async function getRecentMentions(serverUrl: string): Promise { +export async function fetchRecentMentions(serverUrl: string): Promise { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { @@ -67,7 +67,7 @@ export async function getRecentMentions(serverUrl: string): Promise { - const database = DatabaseManager.serverDatabases[serverUrl]; - if (!database) { + const operator = DatabaseManager.serverDatabases[serverUrl].operator; + if (!operator) { return; } try { const preference = JSON.parse(msg.data.preference) as PreferenceType; - const operator = database?.operator; + handleSavePostAdded(serverUrl, [preference]); if (operator) { operator.handlePreferences({ prepareRecordsOnly: false, @@ -25,14 +28,14 @@ export async function handlePreferenceChangedEvent(serverUrl: string, msg: WebSo } export async function handlePreferencesChangedEvent(serverUrl: string, msg: WebSocketMessage): Promise { - const database = DatabaseManager.serverDatabases[serverUrl]; - if (!database) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { return; } try { const preferences = JSON.parse(msg.data.preferences) as PreferenceType[]; - const operator = database?.operator; + handleSavePostAdded(serverUrl, preferences); if (operator) { operator.handlePreferences({ prepareRecordsOnly: false, @@ -57,3 +60,19 @@ export async function handlePreferencesDeletedEvent(serverUrl: string, msg: WebS // Do nothing } } + +// If preferences include new save posts we fetch them +async function handleSavePostAdded(serverUrl: string, preferences: PreferenceType[]) { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return; + } + + const savedPosts = preferences.filter((p) => p.category === Preferences.CATEGORY_SAVED_POST); + for await (const saved of savedPosts) { + const post = await queryPostById(database, saved.name); + if (!post) { + await fetchPostById(serverUrl, saved.name, false); + } + } +} diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index 764e8011e..6e85fd331 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -17,7 +17,7 @@ export interface ClientPostsMix { getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number) => Promise; getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number) => Promise; getFileInfosForPost: (postId: string) => Promise; - getSavedPosts: (userId: string, channelId?: string, teamId?: string, page?: number, perPage?: number) => Promise; + getSavedPosts: (userId: string, channelId?: string, teamId?: string, page?: number, perPage?: number) => Promise; getPinnedPosts: (channelId: string) => Promise; markPostAsUnread: (userId: string, postId: string) => Promise; pinPost: (postId: string) => Promise; diff --git a/app/components/markdown/index.tsx b/app/components/markdown/index.tsx index 1f6d1aacd..6051ee0e2 100644 --- a/app/components/markdown/index.tsx +++ b/app/components/markdown/index.tsx @@ -45,6 +45,7 @@ type MarkdownProps = { isEdited?: boolean; isReplyPost?: boolean; isSearchResult?: boolean; + layoutWidth?: number; location?: string; mentionKeys?: UserMentionKey[]; minimumHashtagLength?: number; @@ -64,6 +65,7 @@ class Markdown extends PureComponent { disableAtChannelMentionHighlight: false, disableChannelLink: false, disableGallery: false, + layoutWidth: undefined, value: '', minimumHashtagLength: 3, }; @@ -208,6 +210,7 @@ class Markdown extends PureComponent { ; isReplyPost?: boolean; linkDestination?: string; + layoutWidth?: number; location?: string; postId: string; source: string; @@ -65,7 +66,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ const MarkdownImage = ({ disabled, errorTextStyle, imagesMetadata, isReplyPost = false, - linkDestination, location, postId, source, sourceSize, + layoutWidth, linkDestination, location, postId, source, sourceSize, }: MarkdownImageProps) => { const intl = useIntl(); const isTablet = useIsTablet(); @@ -76,7 +77,7 @@ const MarkdownImage = ({ const tapRef = useRef(); const metadata = imagesMetadata?.[source] || Object.values(imagesMetadata || {})?.[0]; const [failed, setFailed] = useState(isGifTooLarge(metadata)); - const originalSize = getMarkdownImageSize(isReplyPost, isTablet, sourceSize, metadata); + const originalSize = getMarkdownImageSize(isReplyPost, isTablet, sourceSize, metadata, layoutWidth); const serverUrl = useServerUrl(); const galleryIdentifier = `${postId}-${genericFileId}-${location}`; const uri = useMemo(() => { @@ -124,7 +125,7 @@ const MarkdownImage = ({ handlePreviewImage, ); - const {height, width} = calculateDimensions(fileInfo.height, fileInfo.width, getViewPortWidth(isReplyPost, isTablet)); + const {height, width} = calculateDimensions(fileInfo.height, fileInfo.width, layoutWidth || getViewPortWidth(isReplyPost, isTablet)); const handleLinkPress = useCallback(() => { if (linkDestination) { diff --git a/app/components/post_list/index.tsx b/app/components/post_list/index.tsx index c1d7eb0f5..b72e6e04f 100644 --- a/app/components/post_list/index.tsx +++ b/app/components/post_list/index.tsx @@ -3,7 +3,7 @@ import {FlatList} from '@stream-io/flat-list-mvcp'; import React, {ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {DeviceEventEmitter, NativeScrollEvent, NativeSyntheticEvent, Platform, StyleProp, StyleSheet, ViewStyle, ViewToken} from 'react-native'; +import {DeviceEventEmitter, NativeScrollEvent, NativeSyntheticEvent, Platform, StyleProp, StyleSheet, ViewStyle} from 'react-native'; import Animated from 'react-native-reanimated'; import {fetchPosts, fetchPostThread} from '@actions/remote/post'; @@ -21,6 +21,7 @@ import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} fro import MoreMessages from './more_messages'; import PostListRefreshControl from './refresh_control'; +import type {ViewableItemsChanged, ViewableItemsChangedListenerEvent} from '@typings/components/post_list'; import type PostModel from '@typings/database/models/servers/post'; type Props = { @@ -44,13 +45,7 @@ type Props = { testID: string; } -type ViewableItemsChanged = { - viewableItems: ViewToken[]; - changed: ViewToken[]; -} - type onScrollEndIndexListenerEvent = (endIndex: number) => void; -type ViewableItemsChangedListenerEvent = (viewableItms: ViewToken[]) => void; type ScrollIndexFailed = { index: number; @@ -170,17 +165,17 @@ const PostList = ({ const viewableItemsMap = viewableItems.reduce((acc: Record, {item, isViewable}) => { if (isViewable) { - acc[item.id] = true; + acc[`${location}-${item.id}`] = true; } return acc; }, {}); - DeviceEventEmitter.emit('scrolled', viewableItemsMap); + DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, viewableItemsMap); if (onViewableItemsChangedListener.current) { onViewableItemsChangedListener.current(viewableItems); } - }, []); + }, [location]); const registerScrollEndIndexListener = useCallback((listener) => { onScrollEndIndexListener.current = listener; @@ -285,7 +280,7 @@ const PostList = ({ } // Skip rendering Flag for the root post in the thread as it is visible in the `Thread Overview` - const skipFlaggedHeader = ( + const skipSaveddHeader = ( location === Screens.THREAD && item.id === rootId ); @@ -296,7 +291,7 @@ const PostList = ({ nextPost, previousPost, shouldRenderReplyButton, - skipFlaggedHeader, + skipSaveddHeader, }; return ( diff --git a/app/components/post_list/post/body/content/image_preview/index.tsx b/app/components/post_list/post/body/content/image_preview/index.tsx index 8364449e3..598f518a9 100644 --- a/app/components/post_list/post/body/content/image_preview/index.tsx +++ b/app/components/post_list/post/body/content/image_preview/index.tsx @@ -33,6 +33,7 @@ type ImagePreviewProps = { expandedLink?: string; isReplyPost: boolean; link: string; + layoutWidth?: number; location: string; metadata: PostMetadata; postId: string; @@ -54,7 +55,7 @@ const style = StyleSheet.create({ }, }); -const ImagePreview = ({expandedLink, isReplyPost, link, location, metadata, postId, theme}: ImagePreviewProps) => { +const ImagePreview = ({expandedLink, isReplyPost, layoutWidth, link, location, metadata, postId, theme}: ImagePreviewProps) => { const galleryIdentifier = `${postId}-ImagePreview-${location}`; const [error, setError] = useState(false); const serverUrl = useServerUrl(); @@ -62,7 +63,7 @@ const ImagePreview = ({expandedLink, isReplyPost, link, location, metadata, post const [imageUrl, setImageUrl] = useState(expandedLink || link); const isTablet = useIsTablet(); const imageProps = metadata.images![link]; - const dimensions = calculateDimensions(imageProps.height, imageProps.width, getViewPortWidth(isReplyPost, isTablet)); + const dimensions = calculateDimensions(imageProps.height, imageProps.width, layoutWidth || getViewPortWidth(isReplyPost, isTablet)); const onError = useCallback(() => { setError(true); diff --git a/app/components/post_list/post/body/content/index.tsx b/app/components/post_list/post/body/content/index.tsx index db7a2a50f..35b8d49f5 100644 --- a/app/components/post_list/post/body/content/index.tsx +++ b/app/components/post_list/post/body/content/index.tsx @@ -15,6 +15,7 @@ import type PostModel from '@typings/database/models/servers/post'; type ContentProps = { isReplyPost: boolean; + layoutWidth?: number; location: string; post: PostModel; theme: Theme; @@ -28,7 +29,7 @@ const contentType: Record = { youtube: 'youtube', }; -const Content = ({isReplyPost, location, post, theme}: ContentProps) => { +const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps) => { let type: string = post.metadata?.embeds?.[0].type as string; if (!type && post.props?.attachments?.length) { type = contentType.app_bindings; @@ -43,6 +44,7 @@ const Content = ({isReplyPost, location, post, theme}: ContentProps) => { return ( { return ( ); @@ -62,6 +65,7 @@ const Content = ({isReplyPost, location, post, theme}: ContentProps) => { return ( { return ( { export type Props = { imageMetadata: PostImage; imageUrl: string; + layoutWidth?: number; location: string; postId: string; theme: Theme; } -const AttachmentImage = ({imageUrl, imageMetadata, location, postId, theme}: Props) => { +const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId, theme}: Props) => { const galleryIdentifier = `${postId}-AttachmentImage-${location}`; const [error, setError] = useState(false); const fileId = useRef(generateId('uid')).current; const isTablet = useIsTablet(); - const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, getViewPortWidth(false, isTablet)); + const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, layoutWidth || getViewPortWidth(false, isTablet)); const style = getStyleSheet(theme); const onError = useCallback(() => { diff --git a/app/components/post_list/post/body/content/message_attachments/index.tsx b/app/components/post_list/post/body/content/message_attachments/index.tsx index 68a833898..02967dbf0 100644 --- a/app/components/post_list/post/body/content/message_attachments/index.tsx +++ b/app/components/post_list/post/body/content/message_attachments/index.tsx @@ -8,6 +8,7 @@ import MessageAttachment from './message_attachment'; type Props = { attachments: MessageAttachment[]; + layoutWidth?: number; location: string; metadata?: PostMetadata; postId: string; @@ -21,7 +22,7 @@ const styles = StyleSheet.create({ }, }); -const MessageAttachments = ({attachments, location, metadata, postId, theme}: Props) => { +const MessageAttachments = ({attachments, layoutWidth, location, metadata, postId, theme}: Props) => { const content = [] as React.ReactNode[]; attachments.forEach((attachment, i) => { @@ -29,6 +30,7 @@ const MessageAttachments = ({attachments, location, metadata, postId, theme}: Pr { }; }); -export default function MessageAttachment({attachment, location, metadata, postId, theme}: Props) { +export default function MessageAttachment({attachment, layoutWidth, location, metadata, postId, theme}: Props) { const style = getStyleSheet(theme); const blockStyles = getMarkdownBlockStyles(theme); const textStyles = getMarkdownTextStyles(theme); @@ -133,6 +134,7 @@ export default function MessageAttachment({attachment, location, metadata, postI { })?.data; }; -const Opengraph = ({isReplyPost, location, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => { +const Opengraph = ({isReplyPost, layoutWidth, location, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => { const intl = useIntl(); const link = metadata.embeds![0]!.url; const openGraphData = selectOpenGraphData(link, metadata); @@ -163,6 +164,7 @@ const Opengraph = ({isReplyPost, location, metadata, postId, showLinkPreviews, t {hasImage && { +const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => { const fileId = useRef(generateId('uid')).current; const dimensions = useWindowDimensions(); const style = getStyleSheet(theme); @@ -62,7 +63,7 @@ const OpengraphImage = ({isReplyPost, location, metadata, openGraphImages, postI const bestDimensions = useMemo(() => ({ height: MAX_IMAGE_HEIGHT, - width: getViewPostWidth(isReplyPost, dimensions.height, dimensions.width), + width: layoutWidth || getViewPostWidth(isReplyPost, dimensions.height, dimensions.width), }), [isReplyPost, dimensions]); const bestImage = getNearestPoint(bestDimensions, openGraphImages, 'width', 'height') as BestImage; const imageUrl = (bestImage.secure_url || bestImage.url)!; @@ -85,7 +86,7 @@ const OpengraphImage = ({isReplyPost, location, metadata, openGraphImages, postI let imageDimensions = bestDimensions; if (ogImage?.width && ogImage?.height) { - imageDimensions = calculateDimensions(ogImage.height, ogImage.width, getViewPostWidth(isReplyPost, dimensions.height, dimensions.width)); + imageDimensions = calculateDimensions(ogImage.height, ogImage.width, (layoutWidth || getViewPostWidth(isReplyPost, dimensions.height, dimensions.width)) - 20); } const onPress = () => { 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 038c6f37e..fa2072e47 100644 --- a/app/components/post_list/post/body/content/youtube/index.tsx +++ b/app/components/post_list/post/body/content/youtube/index.tsx @@ -24,6 +24,7 @@ import type SystemModel from '@typings/database/models/servers/system'; type YouTubeProps = { googleDeveloperKey?: string; isReplyPost: boolean; + layoutWidth?: number; metadata: PostMetadata; } @@ -51,7 +52,7 @@ const styles = StyleSheet.create({ }, }); -const YouTube = ({googleDeveloperKey, isReplyPost, metadata}: YouTubeProps) => { +const YouTube = ({googleDeveloperKey, isReplyPost, layoutWidth, metadata}: YouTubeProps) => { const intl = useIntl(); const isTablet = useIsTablet(); const link = metadata.embeds![0].url; @@ -59,7 +60,7 @@ const YouTube = ({googleDeveloperKey, isReplyPost, metadata}: YouTubeProps) => { const dimensions = calculateDimensions( MAX_YOUTUBE_IMAGE_HEIGHT, MAX_YOUTUBE_IMAGE_WIDTH, - getViewPortWidth(isReplyPost, isTablet), + layoutWidth || getViewPortWidth(isReplyPost, isTablet), ); const getYouTubeTime = () => { diff --git a/app/components/post_list/post/body/files/files.tsx b/app/components/post_list/post/body/files/files.tsx index 36e9adb65..f62f173a5 100644 --- a/app/components/post_list/post/body/files/files.tsx +++ b/app/components/post_list/post/body/files/files.tsx @@ -6,6 +6,7 @@ import {DeviceEventEmitter, StyleProp, StyleSheet, View, ViewStyle} from 'react- import Animated, {useDerivedValue} from 'react-native-reanimated'; import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file'; +import {Events} from '@constants'; import {GalleryInit} from '@context/gallery'; import {useServerUrl} from '@context/server'; import {useIsTablet} from '@hooks/device'; @@ -23,6 +24,7 @@ type FilesProps = { canDownloadFiles: boolean; failed?: boolean; files: FileModel[]; + layoutWidth?: number; location: string; isReplyPost: boolean; postId: string; @@ -48,7 +50,7 @@ const styles = StyleSheet.create({ }, }); -const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, location, postId, publicLinkEnabled, theme}: FilesProps) => { +const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, layoutWidth, location, postId, publicLinkEnabled, theme}: FilesProps) => { const galleryIdentifier = `${postId}-fileAttachments-${location}`; const [inViewPort, setInViewPort] = useState(false); const serverUrl = useServerUrl(); @@ -130,7 +132,7 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, location nonVisibleImagesCount={nonVisibleImagesCount} publicLinkEnabled={publicLinkEnabled} updateFileForGallery={updateFileForGallery} - wrapperWidth={getViewPortWidth(isReplyPost, isTablet) - 15} + wrapperWidth={layoutWidth || (getViewPortWidth(isReplyPost, isTablet) - 15)} inViewPort={inViewPort} /> @@ -144,7 +146,7 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, location } const visibleImages = imageAttachments.slice(0, MAX_VISIBLE_ROW_IMAGES); - const portraitPostWidth = getViewPortWidth(isReplyPost, isTablet) - 15; + const portraitPostWidth = layoutWidth || (getViewPortWidth(isReplyPost, isTablet) - 15); let nonVisibleImagesCount; if (imageAttachments.length > MAX_VISIBLE_ROW_IMAGES) { @@ -159,8 +161,8 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, location }; useEffect(() => { - const onScrollEnd = DeviceEventEmitter.addListener('scrolled', (viewableItems) => { - if (postId in viewableItems) { + const onScrollEnd = DeviceEventEmitter.addListener(Events.ITEM_IN_VIEWPORT, (viewableItems) => { + if (`${location}-${postId}` in viewableItems) { setInViewPort(true); } }); diff --git a/app/components/post_list/post/body/index.tsx b/app/components/post_list/post/body/index.tsx index ff50ac436..55e685108 100644 --- a/app/components/post_list/post/body/index.tsx +++ b/app/components/post_list/post/body/index.tsx @@ -1,11 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback} from 'react'; -import {StyleProp, View, ViewStyle} from 'react-native'; +import React, {useCallback, useState} from 'react'; +import {LayoutChangeEvent, StyleProp, View, ViewStyle} from 'react-native'; import FormattedText from '@components/formatted_text'; import JumboEmoji from '@components/jumbo_emoji'; +import {Screens} from '@constants'; import {THREAD} from '@constants/screens'; import {isEdited as postEdited} from '@utils/post'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -78,6 +79,7 @@ const Body = ({ }: BodyProps) => { const style = getStyleSheet(theme); const isEdited = postEdited(post); + const [layoutWidth, setLayoutWidth] = useState(0); const hasBeenDeleted = Boolean(post.deleteAt); let body; let message; @@ -107,6 +109,12 @@ const Body = ({ return barStyle; }, []); + const onLayout = useCallback((e: LayoutChangeEvent) => { + if (location === Screens.SAVED_POSTS) { + setLayoutWidth(e.nativeEvent.layout.width); + } + }, [location]); + if (hasBeenDeleted) { body = ( + {body} {post.props?.failed && diff --git a/app/components/post_list/post/body/message/message.tsx b/app/components/post_list/post/body/message/message.tsx index 773026d9e..8a0bbb5be 100644 --- a/app/components/post_list/post/body/message/message.tsx +++ b/app/components/post_list/post/body/message/message.tsx @@ -22,6 +22,7 @@ type MessageProps = { isEdited: boolean; isPendingOrFailed: boolean; isReplyPost: boolean; + layoutWidth?: number; location: string; post: PostModel; theme: Theme; @@ -49,7 +50,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPost, location, post, theme}: MessageProps) => { +const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPost, layoutWidth, location, post, theme}: MessageProps) => { const [open, setOpen] = useState(false); const [height, setHeight] = useState(); const dimensions = useWindowDimensions(); @@ -87,6 +88,7 @@ const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPo isEdited={isEdited} isReplyPost={isReplyPost} isSearchResult={location === SEARCH} + layoutWidth={layoutWidth} location={location} postId={post.id} textStyles={textStyles} diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index 52a3ec43f..637eb114d 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -79,7 +79,7 @@ const Header = (props: HeaderProps) => { const style = getStyleSheet(theme); const pendingPostStyle = isPendingOrFailed ? style.pendingPost : undefined; const isReplyPost = Boolean(post.rootId && !isEphemeral); - const showReply = !isReplyPost && (location !== THREAD) && (shouldRenderReplyButton || (!rootPostAuthor && commentCount > 0)); + const showReply = !isReplyPost && (location !== THREAD) && (shouldRenderReplyButton && (!rootPostAuthor && commentCount > 0)); const displayName = postUserDisplayName(post, author, teammateNameDisplay, enablePostUsernameOverride); const rootAuthorDisplayName = rootPostAuthor ? displayUsername(rootPostAuthor, currentUser.locale, teammateNameDisplay, true) : undefined; const customStatus = getUserCustomStatus(author); diff --git a/app/screens/home/recent_mentions/components/channel_info/channel_info.tsx b/app/components/post_with_channel_info/channel_info/channel_info.tsx similarity index 100% rename from app/screens/home/recent_mentions/components/channel_info/channel_info.tsx rename to app/components/post_with_channel_info/channel_info/channel_info.tsx diff --git a/app/screens/home/recent_mentions/components/channel_info/index.ts b/app/components/post_with_channel_info/channel_info/index.ts similarity index 100% rename from app/screens/home/recent_mentions/components/channel_info/index.ts rename to app/components/post_with_channel_info/channel_info/index.ts diff --git a/app/components/post_with_channel_info/index.tsx b/app/components/post_with_channel_info/index.tsx new file mode 100644 index 000000000..f3c168938 --- /dev/null +++ b/app/components/post_with_channel_info/index.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {memo} from 'react'; +import {View, StyleSheet} from 'react-native'; + +import Post from '@components/post_list/post'; + +import ChannelInfo from './channel_info'; + +import type PostModel from '@typings/database/models/servers/post'; + +type Props = { + post: PostModel; + location: string; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 20, + }, + content: { + flexDirection: 'row', + paddingBottom: 8, + }, +}); + +function PostWithChannelInfo({post, location}: Props) { + return ( + + + + + + + ); +} + +export default memo(PostWithChannelInfo); diff --git a/app/components/tablet_title/index.tsx b/app/components/tablet_title/index.tsx index 5ffc23f03..72262830e 100644 --- a/app/components/tablet_title/index.tsx +++ b/app/components/tablet_title/index.tsx @@ -11,7 +11,7 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; type Props = { action?: string; enabled?: boolean; - onPress: () => void; + onPress?: () => void; title: string; testID: string; } diff --git a/app/constants/events.ts b/app/constants/events.ts index 3ae3b42be..8adca1fe8 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -21,4 +21,5 @@ export default keyMirror({ USER_STOP_TYPING: null, POST_LIST_SCROLL_TO_BOTTOM: null, SWIPEABLE: null, + ITEM_IN_VIEWPORT: null, }); diff --git a/app/constants/preferences.ts b/app/constants/preferences.ts index 552287213..0a962329a 100644 --- a/app/constants/preferences.ts +++ b/app/constants/preferences.ts @@ -30,6 +30,7 @@ const Preferences: Record = { DISPLAY_PREFER_FULL_NAME: 'full_name', DISPLAY_PREFER_USERNAME: 'username', EMOJI_SKINTONE: 'emoji_skintone', + LINK_PREVIEW_DISPLAY: 'link_previews', MENTION_KEYS: 'mention_keys', USE_MILITARY_TIME: 'use_military_time', CATEGORY_SIDEBAR_SETTINGS: 'sidebar_settings', diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 38cddd460..815d0ae26 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -32,6 +32,7 @@ export const SSO = 'SSO'; export const THREAD = 'Thread'; export const USER_PROFILE = 'UserProfile'; export const POST_OPTIONS = 'PostOptions'; +export const SAVED_POSTS = 'SavedPosts'; export default { ABOUT, @@ -65,4 +66,5 @@ export default { THREAD, USER_PROFILE, POST_OPTIONS, + SAVED_POSTS, }; diff --git a/app/screens/home/account/components/options/saved_messages/index.tsx b/app/screens/home/account/components/options/saved_messages/index.tsx index 8ec189345..9dfc31582 100644 --- a/app/screens/home/account/components/options/saved_messages/index.tsx +++ b/app/screens/home/account/components/options/saved_messages/index.tsx @@ -2,10 +2,14 @@ // See LICENSE.txt for license information. import React, {useCallback} from 'react'; -import {TextStyle} from 'react-native'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter, TextStyle} from 'react-native'; +import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import MenuItem from '@components/menu_item'; +import {Events, Screens} from '@constants'; +import {showModal} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; type Props = { @@ -15,8 +19,29 @@ type Props = { } const SavedMessages = ({isTablet, style, theme}: Props) => { + const intl = useIntl(); const openSavedMessages = useCallback(preventDoubleTap(() => { - // TODO: Open Saved messages screen in either a screen or in line for tablets + if (isTablet) { + DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, Screens.SAVED_POSTS); + } else { + const closeButtonId = 'close-saved-posts'; + const icon = CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor); + const options = { + topBar: { + leftButtons: [{ + id: closeButtonId, + testID: closeButtonId, + icon, + }], + }, + }; + showModal( + Screens.SAVED_POSTS, + intl.formatMessage({id: 'mobile.screen.saved_posts', defaultMessage: 'Saved Messages'}), + {closeButtonId}, + options, + ); + } }), [isTablet]); return ( diff --git a/app/screens/home/account/components/tablet_view/index.ts b/app/screens/home/account/components/tablet_view/index.ts index 37c410e5b..9973e6574 100644 --- a/app/screens/home/account/components/tablet_view/index.ts +++ b/app/screens/home/account/components/tablet_view/index.ts @@ -7,6 +7,7 @@ import {DeviceEventEmitter} from 'react-native'; import {Events, Screens} from '@constants'; import CustomStatus from '@screens/custom_status'; import EditProfile from '@screens/edit_profile'; +import SavedPosts from '@screens/home/saved_posts'; type SelectedView = { id: string; @@ -16,6 +17,7 @@ type SelectedView = { const TabletView: Record = { [Screens.CUSTOM_STATUS]: CustomStatus, [Screens.EDIT_PROFILE]: EditProfile, + [Screens.SAVED_POSTS]: SavedPosts, }; const AccountTabletView = () => { diff --git a/app/screens/home/recent_mentions/components/empty.tsx b/app/screens/home/recent_mentions/components/empty.tsx index 43684ac4a..499d12527 100644 --- a/app/screens/home/recent_mentions/components/empty.tsx +++ b/app/screens/home/recent_mentions/components/empty.tsx @@ -8,8 +8,7 @@ import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; -// @ts-expect-error svg extension -import Mention from './mention_icon.svg'; +import Mention from './mention_icon'; const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ container: { diff --git a/app/screens/home/recent_mentions/components/mention/index.ts b/app/screens/home/recent_mentions/components/mention/index.ts deleted file mode 100644 index 72cd5b649..000000000 --- a/app/screens/home/recent_mentions/components/mention/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import withObservables from '@nozbe/with-observables'; - -import Mention from './mention'; - -const enhance = withObservables(['post'], ({post}) => ({ - post, -})); - -export default enhance(Mention); diff --git a/app/screens/home/recent_mentions/components/mention/mention.tsx b/app/screens/home/recent_mentions/components/mention/mention.tsx deleted file mode 100644 index 000472f7d..000000000 --- a/app/screens/home/recent_mentions/components/mention/mention.tsx +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {View, StyleSheet} from 'react-native'; - -import Avatar from '@components/post_list/post/avatar'; -import Message from '@components/post_list/post/body/message'; -import Header from '@components/post_list/post/header'; -import SystemAvatar from '@components/system_avatar'; -import SystemHeader from '@components/system_header'; -import {Screens} from '@constants'; -import {useTheme} from '@context/theme'; -import {fromAutoResponder, isFromWebhook, isSystemMessage, isEdited as postEdited} from '@utils/post'; - -import ChannelInfo from '../channel_info'; - -import type PostModel from '@typings/database/models/servers/post'; -import type UserModel from '@typings/database/models/servers/user'; - -type Props = { - currentUser: UserModel; - post: PostModel; -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingHorizontal: 20, - }, - content: { - flexDirection: 'row', - paddingBottom: 8, - }, - message: { - flex: 1, - }, - profilePictureContainer: { - marginBottom: 5, - marginRight: 10, - marginTop: 10, - }, -}); - -function Mention({post, currentUser}: Props) { - const theme = useTheme(); - - const isAutoResponder = fromAutoResponder(post); - const isSystemPost = isSystemMessage(post); - const isWebHook = isFromWebhook(post); - const isEdited = postEdited(post); - - const postAvatar = ( - - {isAutoResponder ? ( - - ) : ( - - )} - - ); - - const header = isSystemPost && !isAutoResponder ? ( - - ) : ( -
- ); - - return ( - - - - {postAvatar} - - {header} - - - - - - - ); -} - -export default Mention; diff --git a/app/screens/home/recent_mentions/components/mention_icon.svg b/app/screens/home/recent_mentions/components/mention_icon.svg deleted file mode 100644 index 6703c22bd..000000000 --- a/app/screens/home/recent_mentions/components/mention_icon.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/app/screens/home/recent_mentions/components/mention_icon.tsx b/app/screens/home/recent_mentions/components/mention_icon.tsx new file mode 100644 index 000000000..666a06721 --- /dev/null +++ b/app/screens/home/recent_mentions/components/mention_icon.tsx @@ -0,0 +1,65 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {ViewStyle} from 'react-native'; +import Svg, {Path, Ellipse} from 'react-native-svg'; + +import {useTheme} from '@context/theme'; + +type Props = { + style: ViewStyle; +} + +function MentionIcon({style}: Props) { + const theme = useTheme(); + + return ( + + + + + + + + + + + ); +} + +export default MentionIcon; diff --git a/app/screens/home/recent_mentions/index.ts b/app/screens/home/recent_mentions/index.ts index 23757a7c9..4450b0672 100644 --- a/app/screens/home/recent_mentions/index.ts +++ b/app/screens/home/recent_mentions/index.ts @@ -39,7 +39,6 @@ const enhance = withObservables([], ({database}: WithDatabaseArgs) => { ).observe(); }), ), - currentUser, currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))), isTimezoneEnabled: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')), diff --git a/app/screens/home/recent_mentions/recent_mentions.tsx b/app/screens/home/recent_mentions/recent_mentions.tsx index ed8ccf850..460dcdcf9 100644 --- a/app/screens/home/recent_mentions/recent_mentions.tsx +++ b/app/screens/home/recent_mentions/recent_mentions.tsx @@ -4,22 +4,23 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import React, {useCallback, useState, useEffect, useMemo} from 'react'; import {useIntl} from 'react-intl'; -import {StyleSheet, View, ActivityIndicator, FlatList} from 'react-native'; +import {StyleSheet, View, ActivityIndicator, FlatList, DeviceEventEmitter} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import {SafeAreaView, Edge} from 'react-native-safe-area-context'; -import {getRecentMentions} from '@actions/remote/search'; +import {fetchRecentMentions} from '@actions/remote/search'; +import PostWithChannelInfo from '@app/components/post_with_channel_info'; import NavigationHeader from '@components/navigation_header'; import DateSeparator from '@components/post_list/date_separator'; +import {Events, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import {UserModel} from '@database/models/server'; import {useCollapsibleHeader} from '@hooks/header'; import {getDateForDateLine, isDateLine, selectOrderedPosts} from '@utils/post_list'; import EmptyState from './components/empty'; -import Mention from './components/mention'; +import type {ViewableItemsChanged} from '@typings/components/post_list'; import type PostModel from '@typings/database/models/servers/post'; const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); @@ -28,7 +29,6 @@ const EDGES: Edge[] = ['bottom', 'left', 'right']; type Props = { currentTimezone: string | null; - currentUser: UserModel; isTimezoneEnabled: boolean; mentions: PostModel[]; } @@ -44,7 +44,7 @@ const styles = StyleSheet.create({ }, }); -const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezoneEnabled}: Props) => { +const RecentMentionsScreen = ({mentions, currentTimezone, isTimezoneEnabled}: Props) => { const theme = useTheme(); const route = useRoute(); const isFocused = useIsFocused(); @@ -65,7 +65,7 @@ const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezon useEffect(() => { setLoading(true); - getRecentMentions(serverUrl).finally(() => { + fetchRecentMentions(serverUrl).finally(() => { setLoading(false); }); }, [serverUrl]); @@ -78,7 +78,7 @@ const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezon const handleRefresh = useCallback(async () => { setRefreshing(true); - await getRecentMentions(serverUrl); + await fetchRecentMentions(serverUrl); setRefreshing(false); }, [serverUrl]); @@ -94,6 +94,21 @@ const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezon }; }, []); + const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => { + if (!viewableItems.length) { + return; + } + + const viewableItemsMap = viewableItems.reduce((acc: Record, {item, isViewable}) => { + if (isViewable) { + acc[`${Screens.MENTIONS}-${item.id}`] = true; + } + return acc; + }, {}); + + DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, viewableItemsMap); + }, []); + const renderEmptyList = useCallback(() => ( {loading ? ( @@ -105,7 +120,7 @@ const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezon )} - ), [loading]); + ), [loading, theme, paddingTop]); const renderItem = useCallback(({item}) => { if (typeof item === 'string') { @@ -122,12 +137,12 @@ const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezon } return ( - ); - }, [currentUser]); + }, []); return ( <> @@ -159,6 +174,7 @@ const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezon onRefresh={handleRefresh} refreshing={refreshing} renderItem={renderItem} + onViewableItemsChanged={onViewableItemsChanged} /> diff --git a/app/screens/home/saved_posts/components/empty.tsx b/app/screens/home/saved_posts/components/empty.tsx new file mode 100644 index 000000000..3c82bf3b3 --- /dev/null +++ b/app/screens/home/saved_posts/components/empty.tsx @@ -0,0 +1,59 @@ +// 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 SavedPostsIcon from './saved_posts_icon'; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 40, + }, + title: { + color: theme.centerChannelColor, + ...typography('Heading', 400), + }, + paragraph: { + marginTop: 8, + textAlign: 'center', + color: changeOpacity(theme.centerChannelColor, 0.72), + ...typography('Body', 200), + }, + icon: { + alignItems: 'center', + justifyContent: 'center', + }, +})); + +function EmptySavedPosts() { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + return ( + + + + + + ); +} + +export default EmptySavedPosts; diff --git a/app/screens/home/saved_posts/components/saved_posts_icon.tsx b/app/screens/home/saved_posts/components/saved_posts_icon.tsx new file mode 100644 index 000000000..c69772dd9 --- /dev/null +++ b/app/screens/home/saved_posts/components/saved_posts_icon.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {ViewStyle} from 'react-native'; +import Svg, {Path, Ellipse} from 'react-native-svg'; + +import {useTheme} from '@context/theme'; + +type Props = { + style: ViewStyle; +} + +export default function SavedPostsIcon({style}: Props) { + const theme = useTheme(); + + return ( + + + + + + + + + + + ); +} diff --git a/app/screens/home/saved_posts/index.ts b/app/screens/home/saved_posts/index.ts new file mode 100644 index 000000000..825e65551 --- /dev/null +++ b/app/screens/home/saved_posts/index.ts @@ -0,0 +1,55 @@ +// 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 {Preferences} from '@app/constants'; +import {SYSTEM_IDENTIFIERS, MM_TABLES} from '@constants/database'; +import {SystemModel, UserModel, PreferenceModel} from '@database/models/server'; +import {getTimezone} from '@utils/user'; + +import SavedMessagesScreen from './saved_posts'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const {USER, SYSTEM, POST, PREFERENCE} = MM_TABLES.SERVER; + +function getPostIDs(preferences: PreferenceModel[]) { + return preferences.map((preference) => preference.name); +} + +const enhance = withObservables([], ({database}: WithDatabaseArgs) => { + const currentUser = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap((currentUserId) => database.get(USER).findAndObserve(currentUserId.value)), + ); + + return { + posts: database.get(PREFERENCE).query( + Q.where('category', Preferences.CATEGORY_SAVED_POST), + Q.where('value', 'true'), + ).observeWithColumns(['name']).pipe( + switchMap((rows) => { + if (!rows.length) { + return of$([]); + } + return of$(getPostIDs(rows)); + }), + switchMap((ids) => { + return database.get(POST).query( + Q.where('id', Q.oneOf(ids)), + Q.sortBy('create_at', Q.asc), + ).observe(); + }), + ), + currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))), + isTimezoneEnabled: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')), + ), + }; +}); + +export default withDatabase(enhance(SavedMessagesScreen)); diff --git a/app/screens/home/saved_posts/saved_posts.tsx b/app/screens/home/saved_posts/saved_posts.tsx new file mode 100644 index 000000000..ae4f9701a --- /dev/null +++ b/app/screens/home/saved_posts/saved_posts.tsx @@ -0,0 +1,180 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter, FlatList, StyleSheet, View} from 'react-native'; +import {EventSubscription, Navigation} from 'react-native-navigation'; +import {Edge, SafeAreaView} from 'react-native-safe-area-context'; + +import {fetchSavedPosts} from '@actions/remote/post'; +import Loading from '@components/loading'; +import DateSeparator from '@components/post_list/date_separator'; +import PostWithChannelInfo from '@components/post_with_channel_info'; +import TabletTitle from '@components/tablet_title'; +import {Events, Screens} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {dismissModal} from '@screens/navigation'; +import {isDateLine, getDateForDateLine, selectOrderedPosts} from '@utils/post_list'; + +import EmptyState from './components/empty'; + +import type {ViewableItemsChanged} from '@typings/components/post_list'; +import type PostModel from '@typings/database/models/servers/post'; + +type Props = { + componentId?: string; + closeButtonId?: string; + currentTimezone: string | null; + isTimezoneEnabled: boolean; + isTablet?: boolean; + posts: PostModel[]; +} + +const edges: Edge[] = ['bottom', 'left', 'right']; + +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, + empty: { + alignItems: 'center', + minHeight: '100%', + justifyContent: 'center', + }, + list: { + paddingVertical: 8, + }, + loading: { + height: 40, + width: 40, + justifyContent: 'center' as const, + }, +}); + +function SavedMessages({ + componentId, + closeButtonId, + posts, + currentTimezone, + isTimezoneEnabled, + isTablet, +}: Props) { + const intl = useIntl(); + 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]); + + useEffect(() => { + fetchSavedPosts(serverUrl).finally(() => { + setLoading(false); + }); + }, []); + + useEffect(() => { + let unsubscribe: EventSubscription | undefined; + if (componentId && closeButtonId) { + unsubscribe = Navigation.events().registerComponentListener({ + navigationButtonPressed: ({buttonId}: { buttonId: string }) => { + switch (buttonId) { + case closeButtonId: + dismissModal({componentId}); + break; + } + }, + }, componentId); + } + + return () => { + unsubscribe?.remove(); + }; + }, [componentId, closeButtonId]); + + const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => { + if (!viewableItems.length) { + return; + } + + const viewableItemsMap = viewableItems.reduce((acc: Record, {item, isViewable}) => { + if (isViewable) { + acc[`${Screens.SAVED_POSTS}-${item.id}`] = true; + } + return acc; + }, {}); + + DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, viewableItemsMap); + }, []); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + await fetchSavedPosts(serverUrl); + setRefreshing(false); + }, [serverUrl]); + + const emptyList = useMemo(() => ( + + {loading ? ( + + ) : ( + + )} + + ), [loading, theme.centerChannelColor]); + + const renderItem = useCallback(({item}) => { + if (typeof item === 'string') { + if (isDateLine(item)) { + return ( + + ); + } + return null; + } + + return ( + + ); + }, [currentTimezone, isTimezoneEnabled, theme]); + + return ( + <> + {isTablet && + + } + + + + + ); +} + +export default SavedMessages; diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 2cd64568b..e516e29a3 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -110,6 +110,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.SSO: screen = withIntl(require('@screens/sso').default); break; + case Screens.SAVED_POSTS: + screen = withServerDatabase((require('@screens/home/saved_posts').default)); + break; case Screens.CREATE_DIRECT_MESSAGE: screen = withServerDatabase((require('@screens/create_direct_message').default)); break; diff --git a/app/screens/post_options/options/copy_permalink_option/index.tsx b/app/screens/post_options/options/copy_permalink_option/index.tsx index acc53520d..fa1109e56 100644 --- a/app/screens/post_options/options/copy_permalink_option/index.tsx +++ b/app/screens/post_options/options/copy_permalink_option/index.tsx @@ -24,7 +24,7 @@ const enhanced = withObservables(['post'], ({post, database}: WithDatabaseArgs & const teamName = combineLatest([channel, currentTeamId]).pipe( switchMap(([c, tid]) => { - const teamId = c.teamId || tid; + const teamId = c.teamId || tid.value; return database. get(TEAM). findAndObserve(teamId). diff --git a/app/utils/markdown/index.ts b/app/utils/markdown/index.ts index 73ed78cf1..76c375f44 100644 --- a/app/utils/markdown/index.ts +++ b/app/utils/markdown/index.ts @@ -219,6 +219,7 @@ export const getMarkdownImageSize = ( isTablet: boolean, sourceSize?: SourceSize, knownSize?: PostImage, + layoutWidth?: number, ) => { let ratioW; let ratioH; @@ -253,6 +254,6 @@ export const getMarkdownImageSize = ( } // When no metadata and source size is not specified (full size svg's) - const width = getViewPortWidth(isReplyPost, isTablet); + const width = layoutWidth || getViewPortWidth(isReplyPost, isTablet); return {width, height: width}; }; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 3279e0051..bf39ab4fb 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -284,7 +284,7 @@ "mobile.oauth.switch_to_browser.error_title": "Sign in error", "mobile.oauth.switch_to_browser.title": "Redirecting...", "mobile.oauth.try_again": "Try again", - "mobile.open_gm.error": "", + "mobile.open_gm.error": "We couldn't open a group message with those users. Please check your connection and try again.", "mobile.permission_denied_dismiss": "Don't Allow", "mobile.permission_denied_retry": "Settings", "mobile.post_info.add_reaction": "Add Reaction", @@ -328,6 +328,7 @@ "mobile.routes.custom_status": "Set a Status", "mobile.routes.table": "Table", "mobile.routes.user_profile": "Profile", + "mobile.screen.saved_posts": "Saved Messages", "mobile.screen.your_profile": "Your Profile", "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.", @@ -415,6 +416,8 @@ "post.options.title": "Options", "posts_view.newMsg": "New Messages", "public_link_copied": "Link copied to clipboard", + "saved_posts.empty.paragraph": "To save something for later, long-press on a message and choose Save from the menu. Saved messages are only visible to you.", + "saved_posts.empty.title": "No saved messages yet", "screen.mentions.subtitle": "Messages you've been mentioned in", "screen.mentions.title": "Recent Mentions", "screen.search.placeholder": "Search messages & files", diff --git a/types/components/post_list.d.ts b/types/components/post_list.d.ts new file mode 100644 index 000000000..bbc48ea1c --- /dev/null +++ b/types/components/post_list.d.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ViewToken} from 'react-native'; + +export type ViewableItemsChanged = { + viewableItems: ViewToken[]; + changed: ViewToken[]; +} + +export type ViewableItemsChangedListenerEvent = (viewableItms: ViewToken[]) => void;