Reduce the amount of queries to display the PostList (#6927)

This commit is contained in:
Elias Nahum
2023-01-03 23:36:31 +02:00
committed by GitHub
parent 7df9801cdc
commit 411a7e22a2
23 changed files with 324 additions and 298 deletions

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type NewMessagesLineProps = {
moreMessages: boolean;
style?: StyleProp<ViewStyle>;
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 = (
<FormattedText
id='posts_view.newMsg'
defaultMessage='New Messages'
style={styles.text}
testID={testID}
/>
);
if (moreMessages) {
text = (
<FormattedText
id='mobile.posts_view.moreMsg'
defaultMessage='More New Messages Above'
style={styles.text}
testID={testID}
/>
);
}
return (
<View style={[styles.container, style]}>
<View style={styles.line}/>
<View style={styles.textContainer}>
{text}
<FormattedText
id='posts_view.newMsg'
defaultMessage='New Messages'
style={styles.text}
testID={testID}
/>
</View>
<View style={styles.line}/>
</View>

View File

@@ -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<UserModel | undefined | null> = 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),

View File

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

View File

@@ -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<ViewStyle>;
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<string>;
}
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<FlatList<string | PostModel>>(null);
const onScrollEndIndexListener = useRef<onScrollEndIndexListenerEvent>();
@@ -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<string | PostModel>) => {
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<PostListItem | PostListOtherItem>) => {
switch (item.type) {
case 'start-of-new-messages':
return (
<NewMessagesLine
key={item.value}
theme={theme}
moreMessages={moreNewMessages && checkForPostId}
testID={`${testID}.new_messages_line`}
style={styles.scale}
/>
);
} else if (isDateLine(item)) {
case 'date':
return (
<DateSeparator
date={getDateForDateLine(item)}
key={item.value}
date={getDateForDateLine(item.value)}
style={styles.scale}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
} else if (isThreadOverview(item)) {
case 'thread-overview':
return (
<ThreadOverview
key={item.value}
rootId={rootId!}
testID={`${testID}.thread_overview`}
style={styles.scale}
/>
);
}
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 (<CombinedUserActivity {...postProps}/>);
}
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 (<Post {...postProps}/>);
}
}
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 (
<Post
isCRTEnabled={isCRTEnabled}
key={post.id}
post={post}
rootId={rootId}
style={styles.scale}
testID={`${testID}.post`}
{...postProps}
/>
);
}, [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}

View File

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

View File

@@ -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 (
<View style={styles.container}>
<ChannelInfo
@@ -37,6 +39,8 @@ function PostWithChannelInfo({isCRTEnabled, post, location, testID}: Props) {
/>
<View style={styles.content}>
<Post
appsEnabled={appsEnabled}
customEmojiNames={customEmojiNames}
isCRTEnabled={isCRTEnabled}
post={post}
location={location}

View File

@@ -14,8 +14,9 @@ import {observeUser} from './user';
import type PostModel from '@typings/database/models/servers/post';
import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel';
import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread';
import type PreferenceModel from '@typings/database/models/servers/preference';
const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES;
const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, PREFERENCE}} = MM_TABLES;
export const prepareDeletePost = async (post: PostModel): Promise<Model[]> => {
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<PreferenceModel>(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)))),
);
};

View File

@@ -149,6 +149,7 @@ const ThreadsList = ({
const renderItem = useCallback(({item}: ListRenderItemInfo<ThreadModel>) => (
<Thread
location={Screens.GLOBAL_THREADS}
key={item.id}
testID={testID}
teammateNameDisplay={teammateNameDisplay}
thread={item}

View File

@@ -8,9 +8,11 @@ import compose from 'lodash/fp/compose';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {queryPostsById} from '@queries/servers/post';
import {observeConfigBooleanValue, observeRecentMentions} from '@queries/servers/system';
import {observeCurrentUser} from '@queries/servers/user';
import {mapCustomEmojiNames} from '@utils/emoji/helpers';
import {getTimezone} from '@utils/user';
import RecentMentionsScreen from './recent_mentions';
@@ -21,6 +23,7 @@ const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
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'),
};
});

View File

@@ -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
</View>
), [loading, theme, paddingTop]);
const renderItem = useCallback(({item}: ListRenderItemInfo<string | PostModel>) => {
if (typeof item === 'string') {
if (isDateLine(item)) {
const renderItem = useCallback(({item}: ListRenderItemInfo<PostListItem | PostListOtherItem>) => {
switch (item.type) {
case 'date':
return (
<DateSeparator
date={getDateForDateLine(item)}
key={item.value}
date={getDateForDateLine(item.value)}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
}
return null;
case 'post':
return (
<PostWithChannelInfo
appsEnabled={appsEnabled}
customEmojiNames={customEmojiNames}
key={item.value.id}
location={Screens.MENTIONS}
post={item.value}
testID='recent_mentions.post_list'
/>
);
default:
return null;
}
return (
<PostWithChannelInfo
location={Screens.MENTIONS}
post={item}
testID='recent_mentions.post_list'
/>
);
}, []);
}, [appsEnabled, customEmojiNames]);
return (
<>

View File

@@ -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'),
};
});

View File

@@ -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) {
</View>
), [loading, theme.buttonBg]);
const renderItem = useCallback(({item}: ListRenderItemInfo<string | PostModel>) => {
if (typeof item === 'string') {
if (isDateLine(item)) {
const renderItem = useCallback(({item}: ListRenderItemInfo<PostListItem | PostListOtherItem>) => {
switch (item.type) {
case 'date':
return (
<DateSeparator
date={getDateForDateLine(item)}
key={item.value}
date={getDateForDateLine(item.value)}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
}
return null;
case 'post':
return (
<PostWithChannelInfo
appsEnabled={appsEnabled}
customEmojiNames={customEmojiNames}
key={item.value.id}
location={Screens.SAVED_MESSAGES}
post={item.value}
testID='saved_messages.post_list'
/>
);
default:
return null;
}
return (
<PostWithChannelInfo
location={Screens.SAVED_MESSAGES}
post={item}
testID='saved_messages.post_list'
/>
);
}, [currentTimezone, isTimezoneEnabled, theme]);
}, [appsEnabled, currentTimezone, customEmojiNames, isTimezoneEnabled, theme]);
return (
<>

View File

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

View File

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

View File

@@ -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<string|PostModel>) => {
if (typeof item === 'string') {
if (isDateLine(item)) {
const renderItem = useCallback(({item}: ListRenderItemInfo<PostListItem | PostListOtherItem>) => {
switch (item.type) {
case 'date':
return (
<DateSeparator
date={getDateForDateLine(item)}
key={item.value}
date={getDateForDateLine(item.value)}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
}
return null;
case 'post':
return (
<PostWithChannelInfo
appsEnabled={appsEnabled}
customEmojiNames={customEmojiNames}
key={item.value.id}
location={Screens.SEARCH}
post={item.value}
testID='search_results.post_list'
/>
);
default:
return null;
}
if ('message' in item) {
return (
<PostWithChannelInfo
location={Screens.SEARCH}
post={item}
testID='search_results.post_list'
/>
);
}
return null;
}, []);
}, [appsEnabled, customEmojiNames]);
const noResults = useMemo(() => (
<NoResultsWithTerm

View File

@@ -37,8 +37,10 @@ const getStyles = (width: number) => {
};
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 = ({
<Animated.View style={[styles.container, transform]}>
<View style={styles.result} >
<PostResults
appsEnabled={appsEnabled}
currentTimezone={currentTimezone}
customEmojiNames={customEmojiNames}
isTimezoneEnabled={isTimezoneEnabled}
posts={posts}
paddingTop={paddingTop}

View File

@@ -6,10 +6,12 @@ import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {observePinnedPostsInChannel} from '@queries/servers/post';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeIsCRTEnabled} from '@queries/servers/thread';
import {observeCurrentUser} from '@queries/servers/user';
import {mapCustomEmojiNames} from '@utils/emoji/helpers';
import {getTimezone} from '@utils/user';
import PinnedMessages from './pinned_messages';
@@ -25,7 +27,11 @@ const enhance = withObservables(['channelId'], ({channelId, database}: Props) =>
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,

View File

@@ -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({
</View>
), [loading, theme.buttonBg]);
const renderItem = useCallback(({item}: ListRenderItemInfo<string | PostModel>) => {
if (typeof item === 'string') {
if (isDateLine(item)) {
const renderItem = useCallback(({item}: ListRenderItemInfo<PostListItem | PostListOtherItem>) => {
switch (item.type) {
case 'date':
return (
<DateSeparator
date={getDateForDateLine(item)}
key={item.value}
date={getDateForDateLine(item.value)}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
}
return null;
case 'post':
return (
<Post
appsEnabled={appsEnabled}
customEmojiNames={customEmojiNames}
highlightPinnedOrSaved={false}
isCRTEnabled={isCRTEnabled}
location={Screens.PINNED_MESSAGES}
key={item.value.id}
nextPost={undefined}
post={item.value}
previousPost={undefined}
showAddReaction={false}
shouldRenderReplyButton={false}
skipSavedHeader={true}
skipPinnedHeader={true}
testID='pinned_messages.post_list.post'
/>
);
default:
return null;
}
return (
<Post
highlightPinnedOrSaved={false}
isCRTEnabled={isCRTEnabled}
location={Screens.PINNED_MESSAGES}
nextPost={undefined}
post={item}
previousPost={undefined}
showAddReaction={false}
shouldRenderReplyButton={false}
skipSavedHeader={true}
skipPinnedHeader={true}
testID='pinned_messages.post_list.post'
/>
);
}, [currentTimezone, isTimezoneEnabled, theme]);
}, [appsEnabled, currentTimezone, customEmojiNames, isTimezoneEnabled, theme]);
return (
<SafeAreaView

View File

@@ -212,6 +212,10 @@ export function getEmojiByName(emojiName: string, customEmojis: CustomEmojiModel
return customEmojis.find((e) => 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

View File

@@ -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<PostModel | string>) {
function combineUserActivityPosts(orderedPosts: PostList) {
let lastPostIsUserActivity = false;
let combinedCount = 0;
const out: Array<PostModel | string> = [];
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<string>(),
): 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<string>(), includePrevNext = false) {
if (posts.length === 0) {
return [];
}
const out: Array<PostModel|string> = [];
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<string>()) {
const orderedPosts = selectOrderedPostsWithPrevAndNext(posts, lastViewedAt, indicateNewMessages, currentUserId, currentUsername, showJoinLeave, timezoneEnabled, currentTimezone, isThreadScreen, savedPostIds);
return combineUserActivityPosts(orderedPosts);
}

View File

@@ -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<PostListItem | PostListOtherItem>;