diff --git a/app/components/post_list/index.ts b/app/components/post_list/index.ts index 601a478adb..bcb76e3cdd 100644 --- a/app/components/post_list/index.ts +++ b/app/components/post_list/index.ts @@ -7,22 +7,32 @@ import React from 'react'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; +import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; +import {observeSavedPostsByIds} from '@queries/servers/post'; import {observeConfigBooleanValue} from '@queries/servers/system'; import {observeCurrentUser} from '@queries/servers/user'; +import {mapCustomEmojiNames} from '@utils/emoji/helpers'; import {getTimezone} from '@utils/user'; import PostList from './post_list'; import type {WithDatabaseArgs} from '@typings/database/database'; +import type PostModel from '@typings/database/models/servers/post'; -const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { +const enhanced = withObservables(['posts'], ({database, posts}: {posts: PostModel[]} & WithDatabaseArgs) => { const currentUser = observeCurrentUser(database); + const postIds = posts.map((p) => p.id); return { + appsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'), currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))), currentUserId: currentUser.pipe((switchMap((user) => of$(user?.id)))), currentUsername: currentUser.pipe((switchMap((user) => of$(user?.username)))), + savedPostIds: observeSavedPostsByIds(database, postIds), + customEmojiNames: queryAllCustomEmojis(database).observe().pipe( + switchMap((customEmojis) => of$(mapCustomEmojiNames(customEmojis))), + ), }; }); diff --git a/app/components/post_list/more_messages/more_messages.tsx b/app/components/post_list/more_messages/more_messages.tsx index cd99bc1c26..835ce0c6be 100644 --- a/app/components/post_list/more_messages/more_messages.tsx +++ b/app/components/post_list/more_messages/more_messages.tsx @@ -17,14 +17,14 @@ import {useIsTablet} from '@hooks/device'; import {makeStyleSheetFromTheme, hexToHue} from '@utils/theme'; import {typography} from '@utils/typography'; -import type PostModel from '@typings/database/models/servers/post'; +import type {PostList} from '@typings/components/post_list'; type Props = { channelId: string; isCRTEnabled?: boolean; isManualUnread?: boolean; newMessageLineIndex: number; - posts: Array; + posts: PostList; registerScrollEndIndexListener: (fn: (endIndex: number) => void) => () => void; registerViewableItemsListener: (fn: (viewableItems: ViewToken[]) => void) => () => void; rootId?: string; @@ -188,7 +188,7 @@ const MoreMessages = ({ return; } - const readCount = posts.slice(0, lastViewableIndex).filter((v) => typeof v !== 'string').length; + const readCount = posts.slice(0, lastViewableIndex).filter((v) => v.type === 'post').length; const totalUnread = localUnreadCount.current - readCount; if (lastViewableIndex >= newMessageLineIndex) { resetCount(); diff --git a/app/components/post_list/new_message_line/index.tsx b/app/components/post_list/new_message_line/index.tsx index 496f5b65ce..1ce138d0ae 100644 --- a/app/components/post_list/new_message_line/index.tsx +++ b/app/components/post_list/new_message_line/index.tsx @@ -9,7 +9,6 @@ import {makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; type NewMessagesLineProps = { - moreMessages: boolean; style?: StyleProp; theme: Theme; testID?: string; @@ -39,34 +38,19 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { }; }); -function NewMessagesLine({moreMessages, style, testID, theme}: NewMessagesLineProps) { +function NewMessagesLine({style, testID, theme}: NewMessagesLineProps) { const styles = getStyleFromTheme(theme); - let text = ( - - ); - - if (moreMessages) { - text = ( - - ); - } - return ( - {text} + diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index 88883ca94d..5c47d4ab32 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -4,36 +4,32 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; import React from 'react'; -import {of as of$, combineLatest, Observable} from 'rxjs'; +import {of as of$, combineLatest} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; -import {Permissions, Preferences} from '@constants'; -import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; +import {Permissions, Preferences, Screens} from '@constants'; import {observePostAuthor, queryPostsBetween} from '@queries/servers/post'; -import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; import {observeCanManageChannelMembers, observePermissionForPost} from '@queries/servers/role'; -import {observeIsPostPriorityEnabled, observeConfigBooleanValue} from '@queries/servers/system'; +import {observeIsPostPriorityEnabled} from '@queries/servers/system'; import {observeThreadById} from '@queries/servers/thread'; import {observeCurrentUser} from '@queries/servers/user'; -import {hasJumboEmojiOnly} from '@utils/emoji/helpers'; import {areConsecutivePosts, isPostEphemeral} from '@utils/post'; import Post from './post'; import type {Database} from '@nozbe/watermelondb'; import type {WithDatabaseArgs} from '@typings/database/database'; -import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; import type PostModel from '@typings/database/models/servers/post'; import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; import type UserModel from '@typings/database/models/servers/user'; type PropsInput = WithDatabaseArgs & { - appsEnabled: boolean; currentUser: UserModel; isCRTEnabled?: boolean; nextPost: PostModel | undefined; post: PostModel; previousPost: PostModel | undefined; + location: string; } function observeShouldHighlightReplyBar(database: Database, currentUser: UserModel, post: PostModel, postsInThread: PostsInThreadModel) { @@ -90,39 +86,35 @@ function isFirstReply(post: PostModel, previousPost?: PostModel) { } const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({ - appsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), currentUser: observeCurrentUser(database), })); const withPost = withObservables( ['currentUser', 'isCRTEnabled', 'post', 'previousPost', 'nextPost'], - ({currentUser, database, isCRTEnabled, post, previousPost, nextPost}: PropsInput) => { - let isJumboEmoji = of$(false); + ({currentUser, database, isCRTEnabled, post, previousPost, nextPost, location}: PropsInput) => { let isLastReply = of$(true); let isPostAddChannelMember = of$(false); const isOwner = currentUser.id === post.userId; - const author: Observable = observePostAuthor(database, post); + const author = post.userId ? observePostAuthor(database, post) : of$(undefined); const canDelete = observePermissionForPost(database, post, currentUser, isOwner ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS, false); const isEphemeral = of$(isPostEphemeral(post)); - const isSaved = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, post.id). - observeWithColumns(['value']).pipe( - switchMap((pref) => of$(Boolean(pref.length))), - distinctUntilChanged(), - ); if (post.props?.add_channel_member && isPostEphemeral(post)) { isPostAddChannelMember = observeCanManageChannelMembers(database, post, currentUser); } - const highlightReplyBar = post.postsInThread.observe().pipe( - switchMap((postsInThreads: PostsInThreadModel[]) => { - if (postsInThreads.length) { - return observeShouldHighlightReplyBar(database, currentUser, post, postsInThreads[0]); - } - return of$(false); - }), - distinctUntilChanged(), - ); + let highlightReplyBar = of$(false); + if (!isCRTEnabled && location === Screens.CHANNEL) { + highlightReplyBar = post.postsInThread.observe().pipe( + switchMap((postsInThreads: PostsInThreadModel[]) => { + if (postsInThreads.length) { + return observeShouldHighlightReplyBar(database, currentUser, post, postsInThreads[0]); + } + return of$(false); + }), + distinctUntilChanged(), + ); + } let differentThreadSequence = true; if (post.rootId) { @@ -130,28 +122,20 @@ const withPost = withObservables( isLastReply = of$(!(nextPost?.rootId === post.rootId)); } - if (post.message.length && !(/^\s{4}/).test(post.message)) { - isJumboEmoji = queryAllCustomEmojis(database).observe().pipe( - switchMap( - // eslint-disable-next-line max-nested-callbacks - (customEmojis: CustomEmojiModel[]) => of$(hasJumboEmojiOnly(post.message, customEmojis.map((c) => c.name))), - ), - distinctUntilChanged(), - ); - } - const hasReplies = observeHasReplies(post); + const hasReplies = observeHasReplies(post);//Need to review and understand + const isConsecutivePost = author.pipe( switchMap((user) => of$(Boolean(post && previousPost && !user?.isBot && areConsecutivePosts(post, previousPost)))), distinctUntilChanged(), ); - const hasFiles = post.files.observe().pipe( - switchMap((ff) => of$(Boolean(ff.length))), + const hasFiles = post.files.observeCount().pipe( + switchMap((c) => of$(c > 0)), distinctUntilChanged(), ); - const hasReactions = post.reactions.observe().pipe( - switchMap((rr) => of$(Boolean(rr.length))), + const hasReactions = post.reactions.observeCount().pipe( + switchMap((c) => of$(c > 0)), distinctUntilChanged(), ); @@ -164,8 +148,6 @@ const withPost = withObservables( isConsecutivePost, isEphemeral, isFirstReply: of$(isFirstReply(post, previousPost)), - isSaved, - isJumboEmoji, isLastReply, isPostAddChannelMember, isPostPriorityEnabled: observeIsPostPriorityEnabled(database), diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index a177816680..4186340ba4 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -18,6 +18,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation'; +import {hasJumboEmojiOnly} from '@utils/emoji/helpers'; import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -39,6 +40,7 @@ type PostProps = { appsEnabled: boolean; canDelete: boolean; currentUser: UserModel; + customEmojiNames: string[]; differentThreadSequence: boolean; hasFiles: boolean; hasReplies: boolean; @@ -50,7 +52,6 @@ type PostProps = { isEphemeral: boolean; isFirstReply?: boolean; isSaved?: boolean; - isJumboEmoji: boolean; isLastReply?: boolean; isPostAddChannelMember: boolean; isPostPriorityEnabled: boolean; @@ -107,8 +108,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const Post = ({ - appsEnabled, canDelete, currentUser, differentThreadSequence, hasFiles, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar, - isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isJumboEmoji, isLastReply, isPostAddChannelMember, isPostPriorityEnabled, + appsEnabled, canDelete, currentUser, customEmojiNames, differentThreadSequence, hasFiles, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar, + isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isLastReply, isPostAddChannelMember, isPostPriorityEnabled, location, post, rootId, hasReactions, searchPatterns, shouldRenderReplyButton, skipSavedHeader, skipPinnedHeader, showAddReaction = true, style, testID, thread, previousPost, }: PostProps) => { @@ -136,6 +137,12 @@ const Post = ({ return false; }, [isConsecutivePost, post, previousPost, isFirstReply]); + const isJumboEmoji = useMemo(() => { + if (post.message.length && !(/^\s{4}/).test(post.message)) { + return hasJumboEmojiOnly(post.message, customEmojiNames); + } + return false; + }, [customEmojiNames, post.message]); const handlePostPress = () => { if ([Screens.SAVED_MESSAGES, Screens.MENTIONS, Screens.SEARCH, Screens.PINNED_MESSAGES].includes(location)) { diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx index 003529fcb6..2c9e91138c 100644 --- a/app/components/post_list/post_list.tsx +++ b/app/components/post_list/post_list.tsx @@ -15,21 +15,23 @@ import ThreadOverview from '@components/post_list/thread_overview'; import {Events, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, isThreadOverview, preparePostList, START_OF_NEW_MESSAGES} from '@utils/post_list'; +import {getDateForDateLine, preparePostList} from '@utils/post_list'; import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} from './config'; import MoreMessages from './more_messages'; import PostListRefreshControl from './refresh_control'; -import type {ViewableItemsChanged, ViewableItemsChangedListenerEvent} from '@typings/components/post_list'; +import type {PostListItem, PostListOtherItem, ViewableItemsChanged, ViewableItemsChangedListenerEvent} from '@typings/components/post_list'; import type PostModel from '@typings/database/models/servers/post'; type Props = { + appsEnabled: boolean; channelId: string; contentContainerStyle?: StyleProp; currentTimezone: string | null; currentUserId: string; currentUsername: string; + customEmojiNames: string[]; disablePullToRefresh?: boolean; highlightedId?: PostModel['id']; highlightPinnedOrSaved?: boolean; @@ -50,6 +52,7 @@ type Props = { testID: string; currentCallBarVisible?: boolean; joinCallBannerVisible?: boolean; + savedPostIds: Set; } type onScrollEndIndexListenerEvent = (endIndex: number) => void; @@ -61,7 +64,7 @@ type ScrollIndexFailed = { }; const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); -const keyExtractor = (item: string | PostModel) => (typeof item === 'string' ? item : item.id); +const keyExtractor = (item: PostListItem | PostListOtherItem) => (item.type === 'post' ? item.value.id : item.value); const styles = StyleSheet.create({ flex: { @@ -81,11 +84,13 @@ const styles = StyleSheet.create({ }); const PostList = ({ + appsEnabled, channelId, contentContainerStyle, currentTimezone, currentUserId, currentUsername, + customEmojiNames, disablePullToRefresh, footer, header, @@ -106,6 +111,7 @@ const PostList = ({ testID, currentCallBarVisible, joinCallBannerVisible, + savedPostIds, }: Props) => { const listRef = useRef>(null); const onScrollEndIndexListener = useRef(); @@ -116,11 +122,11 @@ const PostList = ({ const theme = useTheme(); const serverUrl = useServerUrl(); const orderedPosts = useMemo(() => { - return preparePostList(posts, lastViewedAt, showNewMessageLine, currentUserId, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, location === Screens.THREAD); - }, [posts, lastViewedAt, showNewMessageLine, currentTimezone, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, location]); + return preparePostList(posts, lastViewedAt, showNewMessageLine, currentUserId, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, location === Screens.THREAD, savedPostIds); + }, [posts, lastViewedAt, showNewMessageLine, currentTimezone, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, location, savedPostIds]); const initialIndex = useMemo(() => { - return orderedPosts.indexOf(START_OF_NEW_MESSAGES); + return orderedPosts.findIndex((i) => i.type === 'start-of-new-messages'); }, [orderedPosts]); useEffect(() => { @@ -220,48 +226,40 @@ const PostList = ({ return removeListener; }, []); - const renderItem = useCallback(({item, index}: ListRenderItemInfo) => { - if (typeof item === 'string') { - if (isStartOfNewMessages(item)) { - // postIds includes a date item after the new message indicator so 2 - // needs to be added to the index for the length check to be correct. - const moreNewMessages = orderedPosts.length === index + 2; - - // The date line and new message line each count for a line. So the - // goal of this is to check for the 3rd previous, which for the start - // of a thread would be null as it doesn't exist. - const checkForPostId = index < orderedPosts.length - 3; - + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + switch (item.type) { + case 'start-of-new-messages': return ( ); - } else if (isDateLine(item)) { + case 'date': return ( ); - } else if (isThreadOverview(item)) { + case 'thread-overview': return ( ); - } - - if (isCombinedUserActivityPost(item)) { + case 'user-activity': { const postProps = { currentUsername, - postId: item, + key: item.value, + postId: item.value, location, style: Platform.OS === 'ios' ? styles.scale : styles.container, testID: `${testID}.combined_user_activity`, @@ -271,71 +269,32 @@ const PostList = ({ return (); } + default: { + const post = item.value; + const skipSaveddHeader = (location === Screens.THREAD && post.id === rootId); + const postProps = { + appsEnabled, + customEmojiNames, + isCRTEnabled, + highlight: highlightedId === post.id, + highlightPinnedOrSaved, + isSaved: post.isSaved, + key: post.id, + location, + nextPost: post.nextPost, + post, + previousPost: post.previousPost, + rootId, + shouldRenderReplyButton, + skipSaveddHeader, + style: styles.scale, + testID: `${testID}.post`, + }; - return null; - } - - let previousPost: PostModel|undefined; - let nextPost: PostModel|undefined; - - const lastPosts = orderedPosts.slice(index + 1); - const immediateLastPost = lastPosts[0]; - - // Post after `Thread Overview` should show user avatar irrespective of being the consecutive post - // So we skip sending previous post to avoid the check for consecutive post - const skipFindingPreviousPost = ( - location === Screens.THREAD && - typeof immediateLastPost === 'string' && - isThreadOverview(immediateLastPost) - ); - - if (!skipFindingPreviousPost) { - const prev = lastPosts.find((v) => typeof v !== 'string'); - if (prev) { - previousPost = prev as PostModel; + return (); } } - - if (index > 0) { - const next = orderedPosts.slice(0, index); - for (let i = next.length - 1; i >= 0; i--) { - const v = next[i]; - if (typeof v !== 'string') { - nextPost = v; - break; - } - } - } - - // Skip rendering Flag for the root post in the thread as it is visible in the `Thread Overview` - const post = item; - const skipSaveddHeader = ( - location === Screens.THREAD && - post.id === rootId - ); - - const postProps = { - highlight: highlightedId === post.id, - highlightPinnedOrSaved, - location, - nextPost, - previousPost, - shouldRenderReplyButton, - skipSaveddHeader, - }; - - return ( - - ); - }, [currentTimezone, highlightPinnedOrSaved, isCRTEnabled, isTimezoneEnabled, orderedPosts, shouldRenderReplyButton, theme]); + }, [appsEnabled, currentTimezone, customEmojiNames, highlightPinnedOrSaved, isCRTEnabled, isTimezoneEnabled, shouldRenderReplyButton, theme]); const scrollToIndex = useCallback((index: number, animated = true, applyOffset = true) => { listRef.current?.scrollToIndex({ @@ -351,7 +310,7 @@ const PostList = ({ if (highlightedId && orderedPosts && !scrolledToHighlighted.current) { scrolledToHighlighted.current = true; // eslint-disable-next-line max-nested-callbacks - const index = orderedPosts.findIndex((p) => typeof p !== 'string' && p.id === highlightedId); + const index = orderedPosts.findIndex((p) => p.type === 'post' && p.value.id === highlightedId); if (index >= 0 && listRef.current) { listRef.current?.scrollToIndex({ animated: true, @@ -387,7 +346,7 @@ const PostList = ({ maxToRenderPerBatch={10} nativeID={nativeID} onEndReached={onEndReached} - onEndReachedThreshold={2} + onEndReachedThreshold={0.9} onScroll={onScroll} onScrollToIndexFailed={onScrollToIndexFailed} onViewableItemsChanged={onViewableItemsChanged} diff --git a/app/components/post_with_channel_info/index.ts b/app/components/post_with_channel_info/index.ts index 22297ee0a6..ac36a6f997 100644 --- a/app/components/post_with_channel_info/index.ts +++ b/app/components/post_with_channel_info/index.ts @@ -3,7 +3,6 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import compose from 'lodash/fp/compose'; import {observeIsCRTEnabled} from '@queries/servers/thread'; @@ -17,7 +16,4 @@ const enhance = withObservables([], ({database}: WithDatabaseArgs) => { }; }); -export default compose( - withDatabase, - enhance, -)(PostWithChannelInfo); +export default withDatabase(enhance(PostWithChannelInfo)); diff --git a/app/components/post_with_channel_info/post_with_channel_info.tsx b/app/components/post_with_channel_info/post_with_channel_info.tsx index 2ed5125276..a4da4b84be 100644 --- a/app/components/post_with_channel_info/post_with_channel_info.tsx +++ b/app/components/post_with_channel_info/post_with_channel_info.tsx @@ -11,6 +11,8 @@ import ChannelInfo from './channel_info'; import type PostModel from '@typings/database/models/servers/post'; type Props = { + appsEnabled: boolean; + customEmojiNames: string[]; isCRTEnabled: boolean; post: PostModel; location: string; @@ -28,7 +30,7 @@ const styles = StyleSheet.create({ }, }); -function PostWithChannelInfo({isCRTEnabled, post, location, testID}: Props) { +function PostWithChannelInfo({appsEnabled, customEmojiNames, isCRTEnabled, post, location, testID}: Props) { return ( => { const preparedModels: Model[] = [post.prepareDestroyPermanently()]; @@ -74,7 +75,7 @@ export const observePost = (database: Database, postId: string) => { }; export const observePostAuthor = (database: Database, post: PostModel) => { - return post.userId ? observeUser(database, post.userId) : of$(null); + return observeUser(database, post.userId); }; export const observePostSaved = (database: Database, postId: string) => { @@ -216,3 +217,15 @@ export const queryPinnedPostsInChannel = (database: Database, channelId: string) export const observePinnedPostsInChannel = (database: Database, channelId: string) => { return queryPinnedPostsInChannel(database, channelId).observe(); }; + +export const observeSavedPostsByIds = (database: Database, postIds: string[]) => { + return database.get(PREFERENCE). + query( + Q.and( + Q.where('category', Preferences.CATEGORY_SAVED_POST), + Q.where('name', Q.oneOf(postIds)), + ), + ).observeWithColumns(['name']).pipe( + switchMap((prefs) => of$(new Set(prefs.map((p) => p.name)))), + ); +}; diff --git a/app/screens/global_threads/threads_list/threads_list.tsx b/app/screens/global_threads/threads_list/threads_list.tsx index dda1999a94..d905e3e04f 100644 --- a/app/screens/global_threads/threads_list/threads_list.tsx +++ b/app/screens/global_threads/threads_list/threads_list.tsx @@ -149,6 +149,7 @@ const ThreadsList = ({ const renderItem = useCallback(({item}: ListRenderItemInfo) => ( { const currentUser = observeCurrentUser(database); return { + appsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), mentions: observeRecentMentions(database).pipe( switchMap((recentMentions) => { if (!recentMentions.length) { @@ -31,6 +34,9 @@ const enhance = withObservables([], ({database}: WithDatabaseArgs) => { ), currentUser, currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))), + customEmojiNames: queryAllCustomEmojis(database).observe().pipe( + switchMap((customEmojis) => of$(mapCustomEmojiNames(customEmojis))), + ), isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'), }; }); diff --git a/app/screens/home/recent_mentions/recent_mentions.tsx b/app/screens/home/recent_mentions/recent_mentions.tsx index 24f5a5f8fa..b6c89c2079 100644 --- a/app/screens/home/recent_mentions/recent_mentions.tsx +++ b/app/screens/home/recent_mentions/recent_mentions.tsx @@ -17,17 +17,19 @@ import {Events, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useCollapsibleHeader} from '@hooks/header'; -import {getDateForDateLine, isDateLine, selectOrderedPosts} from '@utils/post_list'; +import {getDateForDateLine, selectOrderedPosts} from '@utils/post_list'; import EmptyState from './components/empty'; -import type {ViewableItemsChanged} from '@typings/components/post_list'; +import type {PostListItem, PostListOtherItem, ViewableItemsChanged} from '@typings/components/post_list'; import type PostModel from '@typings/database/models/servers/post'; const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); const EDGES: Edge[] = ['bottom', 'left', 'right']; type Props = { + appsEnabled: boolean; + customEmojiNames: string[]; currentTimezone: string | null; isTimezoneEnabled: boolean; mentions: PostModel[]; @@ -47,7 +49,7 @@ const styles = StyleSheet.create({ }, }); -const RecentMentionsScreen = ({mentions, currentTimezone, isTimezoneEnabled}: Props) => { +const RecentMentionsScreen = ({appsEnabled, customEmojiNames, mentions, currentTimezone, isTimezoneEnabled}: Props) => { const theme = useTheme(); const route = useRoute(); const isFocused = useIsFocused(); @@ -134,27 +136,31 @@ const RecentMentionsScreen = ({mentions, currentTimezone, isTimezoneEnabled}: Pr ), [loading, theme, paddingTop]); - const renderItem = useCallback(({item}: ListRenderItemInfo) => { - if (typeof item === 'string') { - if (isDateLine(item)) { + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + switch (item.type) { + case 'date': return ( ); - } - return null; + case 'post': + return ( + + ); + default: + return null; } - - return ( - - ); - }, []); + }, [appsEnabled, customEmojiNames]); return ( <> diff --git a/app/screens/home/saved_messages/index.ts b/app/screens/home/saved_messages/index.ts index b37970dee4..03d3ca1bbe 100644 --- a/app/screens/home/saved_messages/index.ts +++ b/app/screens/home/saved_messages/index.ts @@ -9,10 +9,12 @@ import {switchMap} from 'rxjs/operators'; import {Preferences} from '@constants'; import {PreferenceModel} from '@database/models/server'; +import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; import {queryPostsById} from '@queries/servers/post'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; import {observeConfigBooleanValue} from '@queries/servers/system'; import {observeCurrentUser} from '@queries/servers/user'; +import {mapCustomEmojiNames} from '@utils/emoji/helpers'; import {getTimezone} from '@utils/user'; import SavedMessagesScreen from './saved_messages'; @@ -39,6 +41,9 @@ const enhance = withObservables([], ({database}: WithDatabaseArgs) => { }), ), currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))), + customEmojiNames: queryAllCustomEmojis(database).observe().pipe( + switchMap((customEmojis) => of$(mapCustomEmojiNames(customEmojis))), + ), isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'), }; }); diff --git a/app/screens/home/saved_messages/saved_messages.tsx b/app/screens/home/saved_messages/saved_messages.tsx index 9572b678fd..92120f9d02 100644 --- a/app/screens/home/saved_messages/saved_messages.tsx +++ b/app/screens/home/saved_messages/saved_messages.tsx @@ -18,15 +18,17 @@ import {Events, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useCollapsibleHeader} from '@hooks/header'; -import {isDateLine, getDateForDateLine, selectOrderedPosts} from '@utils/post_list'; +import {getDateForDateLine, selectOrderedPosts} from '@utils/post_list'; import EmptyState from './components/empty'; -import type {ViewableItemsChanged} from '@typings/components/post_list'; +import type {PostListItem, PostListOtherItem, ViewableItemsChanged} from '@typings/components/post_list'; import type PostModel from '@typings/database/models/servers/post'; type Props = { + appsEnabled: boolean; currentTimezone: string | null; + customEmojiNames: string[]; isTimezoneEnabled: boolean; posts: PostModel[]; } @@ -48,7 +50,7 @@ const styles = StyleSheet.create({ }, }); -function SavedMessages({posts, currentTimezone, isTimezoneEnabled}: Props) { +function SavedMessages({appsEnabled, posts, currentTimezone, customEmojiNames, isTimezoneEnabled}: Props) { const intl = useIntl(); const [loading, setLoading] = useState(!posts.length); const [refreshing, setRefreshing] = useState(false); @@ -135,27 +137,31 @@ function SavedMessages({posts, currentTimezone, isTimezoneEnabled}: Props) { ), [loading, theme.buttonBg]); - const renderItem = useCallback(({item}: ListRenderItemInfo) => { - if (typeof item === 'string') { - if (isDateLine(item)) { + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + switch (item.type) { + case 'date': return ( ); - } - return null; + case 'post': + return ( + + ); + default: + return null; } - - return ( - - ); - }, [currentTimezone, isTimezoneEnabled, theme]); + }, [appsEnabled, currentTimezone, customEmojiNames, isTimezoneEnabled, theme]); return ( <> diff --git a/app/screens/home/search/results/file_results.tsx b/app/screens/home/search/results/file_results.tsx index fdf6950fb5..d1e2aa08c6 100644 --- a/app/screens/home/search/results/file_results.tsx +++ b/app/screens/home/search/results/file_results.tsx @@ -94,6 +94,7 @@ const FileResults = ({ channelName={channelNames[item.channel_id!]} fileInfo={item} index={fileInfosIndexes[item.id!] || 0} + key={`${item.id}-${item.name}`} numOptions={numOptions} onOptionsPress={onOptionsPress} onPress={onPreviewPress} diff --git a/app/screens/home/search/results/index.tsx b/app/screens/home/search/results/index.tsx index 9605a13f6a..2e0a9167c2 100644 --- a/app/screens/home/search/results/index.tsx +++ b/app/screens/home/search/results/index.tsx @@ -8,8 +8,10 @@ import {combineLatest, of as of$} from 'rxjs'; import {map, switchMap} from 'rxjs/operators'; import {queryChannelsById} from '@queries/servers/channel'; +import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; import {observeLicense, observeConfigBooleanValue} from '@queries/servers/system'; import {observeCurrentUser} from '@queries/servers/user'; +import {mapCustomEmojiNames} from '@utils/emoji/helpers'; import {getTimezone} from '@utils/user'; import Results from './results'; @@ -35,7 +37,11 @@ const enhance = withObservables(['fileChannelIds'], ({database, fileChannelIds}: ); return { + appsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone))))), + customEmojiNames: queryAllCustomEmojis(database).observe().pipe( + switchMap((customEmojis) => of$(mapCustomEmojiNames(customEmojis))), + ), isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'), fileChannels, canDownloadFiles, diff --git a/app/screens/home/search/results/post_results.tsx b/app/screens/home/search/results/post_results.tsx index 8e6b457a55..1d92d16ab9 100644 --- a/app/screens/home/search/results/post_results.tsx +++ b/app/screens/home/search/results/post_results.tsx @@ -8,12 +8,15 @@ import NoResultsWithTerm from '@components/no_results_with_term'; import DateSeparator from '@components/post_list/date_separator'; import PostWithChannelInfo from '@components/post_with_channel_info'; import {Screens} from '@constants'; -import {getDateForDateLine, isDateLine, selectOrderedPosts} from '@utils/post_list'; +import {getDateForDateLine, selectOrderedPosts} from '@utils/post_list'; import {TabTypes} from '@utils/search'; +import type {PostListItem, PostListOtherItem} from '@typings/components/post_list'; import type PostModel from '@typings/database/models/servers/post'; type Props = { + appsEnabled: boolean; + customEmojiNames: string[]; currentTimezone: string; isTimezoneEnabled: boolean; posts: PostModel[]; @@ -22,7 +25,9 @@ type Props = { } const PostResults = ({ + appsEnabled, currentTimezone, + customEmojiNames, isTimezoneEnabled, posts, paddingTop, @@ -31,30 +36,31 @@ const PostResults = ({ const orderedPosts = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, isTimezoneEnabled, currentTimezone, false).reverse(), [posts]); const containerStyle = useMemo(() => ({top: posts.length ? 4 : 8}), [posts]); - const renderItem = useCallback(({item}: ListRenderItemInfo) => { - if (typeof item === 'string') { - if (isDateLine(item)) { + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + switch (item.type) { + case 'date': return ( ); - } - return null; + case 'post': + return ( + + ); + default: + return null; } - - if ('message' in item) { - return ( - - ); - } - return null; - }, []); + }, [appsEnabled, customEmojiNames]); const noResults = useMemo(() => ( { }; type Props = { + appsEnabled: boolean; canDownloadFiles: boolean; currentTimezone: string; + customEmojiNames: string[]; fileChannels: ChannelModel[]; fileInfos: FileInfo[]; isTimezoneEnabled: boolean; @@ -51,8 +53,10 @@ type Props = { } const Results = ({ + appsEnabled, canDownloadFiles, currentTimezone, + customEmojiNames, fileChannels, fileInfos, isTimezoneEnabled, @@ -96,7 +100,9 @@ const Results = ({ const posts = observePinnedPostsInChannel(database, channelId); return { + appsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))), + customEmojiNames: queryAllCustomEmojis(database).observe().pipe( + switchMap((customEmojis) => of$(mapCustomEmojiNames(customEmojis))), + ), isCRTEnabled: observeIsCRTEnabled(database), isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'), posts, diff --git a/app/screens/pinned_messages/pinned_messages.tsx b/app/screens/pinned_messages/pinned_messages.tsx index f4b46d74a9..291d241704 100644 --- a/app/screens/pinned_messages/pinned_messages.tsx +++ b/app/screens/pinned_messages/pinned_messages.tsx @@ -14,17 +14,19 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {popTopScreen} from '@screens/navigation'; -import {getDateForDateLine, isDateLine, selectOrderedPosts} from '@utils/post_list'; +import {getDateForDateLine, selectOrderedPosts} from '@utils/post_list'; import EmptyState from './empty'; -import type {ViewableItemsChanged} from '@typings/components/post_list'; +import type {PostListItem, PostListOtherItem, ViewableItemsChanged} from '@typings/components/post_list'; import type PostModel from '@typings/database/models/servers/post'; type Props = { + appsEnabled: boolean; channelId: string; componentId: string; currentTimezone: string | null; + customEmojiNames: string[]; isCRTEnabled: boolean; isTimezoneEnabled: boolean; posts: PostModel[]; @@ -47,9 +49,11 @@ const styles = StyleSheet.create({ }); function SavedMessages({ + appsEnabled, channelId, componentId, currentTimezone, + customEmojiNames, isCRTEnabled, isTimezoneEnabled, posts, @@ -109,35 +113,39 @@ function SavedMessages({ ), [loading, theme.buttonBg]); - const renderItem = useCallback(({item}: ListRenderItemInfo) => { - if (typeof item === 'string') { - if (isDateLine(item)) { + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + switch (item.type) { + case 'date': return ( ); - } - return null; + case 'post': + return ( + + ); + default: + return null; } - - return ( - - ); - }, [currentTimezone, isTimezoneEnabled, theme]); + }, [appsEnabled, currentTimezone, customEmojiNames, isTimezoneEnabled, theme]); return ( e.name === emojiName); } +export function mapCustomEmojiNames(customEmois: CustomEmojiModel[]) { + return customEmois.map((c) => c.name); +} + // Since there is no shared logic between the web and mobile app // this is copied from the webapp as custom sorting logic for emojis diff --git a/app/utils/post_list/index.ts b/app/utils/post_list/index.ts index c2f7be4377..86e2f8ab78 100644 --- a/app/utils/post_list/index.ts +++ b/app/utils/post_list/index.ts @@ -7,6 +7,7 @@ import {Post} from '@constants'; import {toMilliseconds} from '@utils/datetime'; import {isFromWebhook} from '@utils/post'; +import type {PostList, PostWithPrevAndNext} from '@typings/components/post_list'; import type PostModel from '@typings/database/models/servers/post'; const joinLeavePostTypes = [ @@ -49,44 +50,37 @@ export const START_OF_NEW_MESSAGES = 'start-of-new-messages'; export const THREAD_OVERVIEW = 'thread-overview'; export const MAX_COMBINED_SYSTEM_POSTS = 100; -function combineUserActivityPosts(orderedPosts: Array) { +function combineUserActivityPosts(orderedPosts: PostList) { let lastPostIsUserActivity = false; let combinedCount = 0; - const out: Array = []; + const out: PostList = []; let changed = false; for (let i = 0; i < orderedPosts.length; i++) { - const post = orderedPosts[i]; + const item = orderedPosts[i]; + if (item.type === 'start-of-new-messages' || item.type === 'date' || item.type === 'thread-overview') { + // Not a post, so it won't be combined + out.push(item); - if (typeof post === 'string') { - if (post === START_OF_NEW_MESSAGES || post.startsWith(DATE_LINE)) { - // Not a post, so it won't be combined - out.push(post); + lastPostIsUserActivity = false; + combinedCount = 0; - lastPostIsUserActivity = false; - combinedCount = 0; - - continue; - } - } else if (post.deleteAt) { - out.push(post); + continue; + } else if (item.type === 'post' && item.value.deleteAt) { + out.push(item); lastPostIsUserActivity = false; combinedCount = 0; } else { - const postIsUserActivity = Post.USER_ACTIVITY_POST_TYPES.includes(post.type); + const postIsUserActivity = item.type === 'post' && Post.USER_ACTIVITY_POST_TYPES.includes(item.value.type); if (postIsUserActivity && lastPostIsUserActivity && combinedCount < MAX_COMBINED_SYSTEM_POSTS) { - // Add the ID to the previous combined post - out[out.length - 1] += '_' + post.id; - combinedCount += 1; - changed = true; + out[out.length - 1].value += '_' + item.value.id; } else if (postIsUserActivity) { - // Start a new combined post, even if the "combined" post is only a single post - out.push(COMBINED_USER_ACTIVITY + post.id); + out.push({type: 'user-activity', value: `${COMBINED_USER_ACTIVITY}${item.value.id}`}); combinedCount = 1; changed = true; } else { - out.push(post); + out.push(item); combinedCount = 0; } @@ -175,21 +169,38 @@ function isJoinLeavePostForUsername(post: PostModel, currentUsername: string): b post.props.removedUsername === currentUsername; } -// are we going to do something with selectedPostId as in v1? +export function selectOrderedPostsWithPrevAndNext( + posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUserId: string, currentUsername: string, showJoinLeave: boolean, + timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false, savedPostIds = new Set(), +): PostList { + return selectOrderedPosts( + posts, lastViewedAt, indicateNewMessages, + currentUserId, currentUsername, showJoinLeave, + timezoneEnabled, currentTimezone, isThreadScreen, savedPostIds, true, + ); +} + export function selectOrderedPosts( posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUserId: string, currentUsername: string, showJoinLeave: boolean, - timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false) { + timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false, savedPostIds = new Set(), includePrevNext = false) { if (posts.length === 0) { return []; } - const out: Array = []; + const out: PostList = []; let lastDate; let addedNewMessagesIndicator = false; // Iterating through the posts from oldest to newest for (let i = posts.length - 1; i >= 0; i--) { - const post = posts[i]; + const post: PostWithPrevAndNext = posts[i]; + post.isSaved = savedPostIds.has(post.id); + if (includePrevNext) { + post.nextPost = posts[i - 1]; + if (!isThreadScreen || out[out.length - 1]?.type !== 'thread-overview') { + post.previousPost = posts[i + 1]; + } + } if ( !post || @@ -217,7 +228,7 @@ export function selectOrderedPosts( } if (!lastDate || lastDate.toDateString() !== postDate.toDateString()) { - out.push(DATE_LINE + postDate.getTime()); + out.push({type: 'date', value: DATE_LINE + postDate.getTime()}); lastDate = postDate; } @@ -229,14 +240,14 @@ export function selectOrderedPosts( !addedNewMessagesIndicator && indicateNewMessages ) { - out.push(START_OF_NEW_MESSAGES); + out.push({type: 'start-of-new-messages', value: START_OF_NEW_MESSAGES}); addedNewMessagesIndicator = true; } - out.push(post); + out.push({type: 'post', value: post}); if (isThreadScreen && i === posts.length - 1) { - out.push(THREAD_OVERVIEW); + out.push({type: 'thread-overview', value: THREAD_OVERVIEW}); } } @@ -350,26 +361,10 @@ export function getPostIdsForCombinedUserActivityPost(item: string) { return item.substring(COMBINED_USER_ACTIVITY.length).split('_'); } -export function isCombinedUserActivityPost(item: string) { - return (/^user-activity-(?:[^_]+_)*[^_]+$/).test(item); -} - -export function isDateLine(item: string) { - return Boolean(item?.startsWith(DATE_LINE)); -} - -export function isStartOfNewMessages(item: string) { - return item === START_OF_NEW_MESSAGES; -} - -export function isThreadOverview(item: string) { - return item === THREAD_OVERVIEW; -} - export function preparePostList( posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUserId: string, currentUsername: string, showJoinLeave: boolean, - timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false) { - const orderedPosts = selectOrderedPosts(posts, lastViewedAt, indicateNewMessages, currentUserId, currentUsername, showJoinLeave, timezoneEnabled, currentTimezone, isThreadScreen); + timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false, savedPostIds = new Set()) { + const orderedPosts = selectOrderedPostsWithPrevAndNext(posts, lastViewedAt, indicateNewMessages, currentUserId, currentUsername, showJoinLeave, timezoneEnabled, currentTimezone, isThreadScreen, savedPostIds); return combineUserActivityPosts(orderedPosts); } diff --git a/types/components/post_list.ts b/types/components/post_list.ts index 99c5344c38..0415faf7d0 100644 --- a/types/components/post_list.ts +++ b/types/components/post_list.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type PostModel from '@typings/database/models/servers/post'; import type {ViewToken} from 'react-native'; export type ViewableItemsChanged = { @@ -12,3 +13,17 @@ export type ViewableItemsChangedListenerEvent = (viewableItms: ViewToken[]) => v export type ScrollEndIndexListener = (fn: (endIndex: number) => void) => () => void; export type ViewableItemsListener = (fn: (viewableItems: ViewToken[]) => void) => () => void; + +export type PostWithPrevAndNext = PostModel & {nextPost?: PostModel; previousPost?: PostModel; isSaved?: boolean}; + +export type PostListItem = { + type: 'post'; + value: PostWithPrevAndNext; +} + +export type PostListOtherItem = { + type: 'date' | 'thread-overview' | 'start-of-new-messages' | 'user-activity'; + value: string; +} + +export type PostList = Array;