[Gekidou MM-40089 MM-39318] CRT New Messages Line (#6489)

* New Messages Line + More Messages

* Misc

* Update app/actions/local/thread.ts

Co-authored-by: Avinash Lingaloo <avinashlng1080@gmail.com>

Co-authored-by: Avinash Lingaloo <avinashlng1080@gmail.com>
This commit is contained in:
Anurag Shivarathri
2022-07-22 17:28:14 +05:30
committed by GitHub
parent d8c77855b1
commit 102789bbd9
13 changed files with 117 additions and 33 deletions

View File

@@ -127,7 +127,7 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
subtitle = subtitle.replace('{channelName}', channel.displayName);
}
EphemeralStore.setLastViewedThreadId(rootId);
EphemeralStore.setCurrentThreadId(rootId);
if (isFromNotification) {
await dismissAllModalsAndPopToRoot();
@@ -267,6 +267,7 @@ export async function markTeamThreadsAsRead(serverUrl: string, teamId: string, p
record.unreadMentions = 0;
record.unreadReplies = 0;
record.lastViewedAt = Date.now();
record.viewedAt = Date.now();
}));
if (!prepareRecordsOnly) {
await operator.batchRecords(models);
@@ -278,7 +279,31 @@ export async function markTeamThreadsAsRead(serverUrl: string, teamId: string, p
}
}
export async function updateThread(serverUrl: string, threadId: string, updatedThread: Partial<Thread>, prepareRecordsOnly = false) {
export async function markThreadAsViewed(serverUrl: string, threadId: string, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const thread = await getThreadById(database, threadId);
if (!thread) {
return {error: 'Thread not found'};
}
thread.prepareUpdate((th) => {
th.viewedAt = thread.lastViewedAt;
th.lastViewedAt = Date.now();
});
if (!prepareRecordsOnly) {
await operator.batchRecords([thread]);
}
return {model: thread};
} catch (error) {
logError('Failed markThreadAsViewed', error);
return {error};
}
}
export async function updateThread(serverUrl: string, threadId: string, updatedThread: Partial<ThreadWithViewedAt>, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const thread = await getThreadById(database, threadId);
@@ -289,8 +314,8 @@ export async function updateThread(serverUrl: string, threadId: string, updatedT
const model = thread.prepareUpdate((record) => {
record.isFollowing = updatedThread.is_following ?? record.isFollowing;
record.replyCount = updatedThread.reply_count ?? record.replyCount;
record.lastViewedAt = updatedThread.last_viewed_at ?? record.lastViewedAt;
record.viewedAt = updatedThread.viewed_at ?? record.viewedAt;
record.unreadMentions = updatedThread.unread_mentions ?? record.unreadMentions;
record.unreadReplies = updatedThread.unread_replies ?? record.unreadReplies;
});

View File

@@ -1,13 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {markTeamThreadsAsRead, processReceivedThreads, switchToThread, updateThread} from '@actions/local/thread';
import {markTeamThreadsAsRead, markThreadAsViewed, processReceivedThreads, switchToThread, updateThread} from '@actions/local/thread';
import {fetchPostThread} from '@actions/remote/post';
import {General} from '@constants';
import DatabaseManager from '@database/manager';
import PushNotifications from '@init/push_notifications';
import NetworkManager from '@managers/network_manager';
import {getChannelById} from '@queries/servers/channel';
import {getPostById} from '@queries/servers/post';
import {getCommonSystemValues, getCurrentTeamId} from '@queries/servers/system';
import {getIsCRTEnabled, getNewestThreadInTeam, getThreadById} from '@queries/servers/thread';
@@ -51,10 +50,7 @@ export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string,
if (post) {
const thread = await getThreadById(database, rootId);
if (thread?.isFollowing) {
const channel = await getChannelById(database, post.channelId);
if (channel) {
markThreadAsRead(serverUrl, channel.teamId, thread.id);
}
markThreadAsViewed(serverUrl, thread.id);
}
}
}
@@ -225,10 +221,11 @@ export const markThreadAsUnread = async (serverUrl: string, teamId: string, thre
const data = await client.markThreadAsUnread('me', threadTeamId, threadId, postId);
// Update locally
const post = await getPostById(database, threadId);
const post = await getPostById(database, postId);
if (post) {
await updateThread(serverUrl, threadId, {
last_viewed_at: post.createAt - 1,
viewed_at: post.createAt - 1,
});
}

View File

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {markTeamThreadsAsRead, processReceivedThreads, updateThread} from '@actions/local/thread';
import EphemeralStore from '@store/ephemeral_store';
export async function handleThreadUpdatedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {
try {
@@ -20,11 +21,19 @@ export async function handleThreadReadChangedEvent(serverUrl: string, msg: WebSo
try {
const {thread_id, timestamp, unread_mentions, unread_replies} = msg.data;
if (thread_id) {
await updateThread(serverUrl, thread_id, {
last_viewed_at: timestamp,
const data: Partial<ThreadWithViewedAt> = {
unread_mentions,
unread_replies,
});
last_viewed_at: timestamp,
};
// Do not update viewing data if the user is currently in the same thread
const isThreadVisible = EphemeralStore.getCurrentThreadId() === thread_id;
if (!isThreadVisible) {
data.viewed_at = timestamp;
}
await updateThread(serverUrl, thread_id, data);
} else {
await markTeamThreadsAsRead(serverUrl, msg.broadcast.team_id);
}

View File

@@ -3,16 +3,33 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {of as of$, first as first$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeMyChannel} from '@queries/servers/channel';
import {observeThreadById} from '@queries/servers/thread';
import MoreMessages from './more_messages';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables(['channelId'], ({channelId, database}: {channelId: string} & WithDatabaseArgs) => {
type Props = {
channelId: string;
isCRTEnabled?: boolean;
rootId?: string;
} & WithDatabaseArgs;
const enhanced = withObservables(['channelId', 'isCRTEnabled', 'rootId'], ({channelId, isCRTEnabled, rootId, database}: Props) => {
if (isCRTEnabled && rootId) {
const thread = observeThreadById(database, rootId);
// Just take the first value emited as we set unreadReplies to 0 on viewing the thread.
const unreadCount = thread.pipe(first$(), switchMap((th) => of$(th?.unreadReplies)));
return {
unreadCount,
};
}
const myChannel = observeMyChannel(database, channelId);
const isManualUnread = myChannel.pipe(switchMap((ch) => of$(ch?.manuallyUnread)));
const unreadCount = myChannel.pipe(switchMap((ch) => of$(ch?.messageCount)));

View File

@@ -19,11 +19,13 @@ import type PostModel from '@typings/database/models/servers/post';
type Props = {
channelId: string;
isManualUnread: boolean;
isCRTEnabled?: boolean;
isManualUnread?: boolean;
newMessageLineIndex: number;
posts: Array<string | PostModel>;
registerScrollEndIndexListener: (fn: (endIndex: number) => void) => () => void;
registerViewableItemsListener: (fn: (viewableItems: ViewToken[]) => void) => () => void;
rootId?: string;
scrollToIndex: (index: number, animated?: boolean, applyOffset?: boolean) => void;
unreadCount: number;
theme: Theme;
@@ -91,11 +93,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const MoreMessages = ({
channelId,
isCRTEnabled,
isManualUnread,
newMessageLineIndex,
posts,
registerViewableItemsListener,
registerScrollEndIndexListener,
rootId,
scrollToIndex,
unreadCount,
testID,
@@ -110,7 +114,7 @@ const MoreMessages = ({
const [remaining, setRemaining] = useState(0);
const underlayColor = useMemo(() => `hsl(${hexToHue(theme.buttonBg)}, 50%, 38%)`, [theme]);
const top = useSharedValue(0);
const shownTop = isTablet ? 5 : SHOWN_TOP;
const shownTop = isTablet || isCRTEnabled ? 5 : SHOWN_TOP;
const BARS_FACTOR = Math.abs((1) / (HIDDEN_TOP - SHOWN_TOP));
const styles = getStyleSheet(theme);
const animatedStyle = useAnimatedStyle(() => ({
@@ -134,8 +138,18 @@ const MoreMessages = ({
}],
}), [isTablet, shownTop]);
// Due to the implementation differences "unreadCount" gets updated for a channel on reset but not for a thread.
// So we maintain a localUnreadCount to hide the indicator when the count is reset.
// If we don't maintain the local counter, in the case of a thread, the indicator will be shown again once we scroll down after we reach the top.
const localUnreadCount = useRef(unreadCount);
useEffect(() => {
localUnreadCount.current = unreadCount;
}, [unreadCount]);
const resetCount = async () => {
if (resetting.current) {
localUnreadCount.current = 0;
if (resetting.current || (isCRTEnabled && rootId)) {
return;
}
@@ -165,7 +179,7 @@ const MoreMessages = ({
}
const readCount = posts.slice(0, lastViewableIndex).filter((v) => typeof v !== 'string').length;
const totalUnread = unreadCount - readCount;
const totalUnread = localUnreadCount.current - readCount;
if (lastViewableIndex >= newMessageLineIndex) {
resetCount();
top.value = 0;

View File

@@ -295,7 +295,7 @@ const Post = ({
let unreadDot;
let footer;
if (isCRTEnabled && thread) {
if (isCRTEnabled && thread && location !== Screens.THREAD) {
if (thread.replyCount > 0 || thread.isFollowing) {
footer = (
<Footer

View File

@@ -379,10 +379,12 @@ const PostList = ({
{showMoreMessages &&
<MoreMessages
channelId={channelId}
isCRTEnabled={isCRTEnabled}
newMessageLineIndex={initialIndex}
posts={orderedPosts}
registerScrollEndIndexListener={registerScrollEndIndexListener}
registerViewableItemsListener={registerViewableItemsListener}
rootId={rootId}
scrollToIndex={scrollToIndex}
theme={theme}
testID={`${testID}.more_messages_button`}

View File

@@ -161,7 +161,7 @@ class PushNotifications {
}
const isDifferentChannel = payload?.channel_id !== channelId;
const isVisibleThread = payload?.root_id === EphemeralStore.getLastViewedThreadId() && NavigationStore.getNavigationTopComponentId() === Screens.THREAD;
const isVisibleThread = payload?.root_id === EphemeralStore.getCurrentThreadId();
let isChannelScreenVisible = NavigationStore.getNavigationTopComponentId() === Screens.CHANNEL;
if (isTabletDevice) {
isChannelScreenVisible = NavigationStore.getVisibleTab() === Screens.HOME;

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef} from 'react';
import React, {useEffect, useRef} from 'react';
import {StyleSheet, View} from 'react-native';
import {KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
@@ -13,6 +13,7 @@ import {THREAD_ACCESSORIES_CONTAINER_NATIVE_ID} from '@constants/post_draft';
import {useAppState} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {popTopScreen} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import ThreadPostList from './thread_post_list';
@@ -33,6 +34,12 @@ const Thread = ({componentId, rootPost}: ThreadProps) => {
const appState = useAppState();
const postDraftRef = useRef<KeyboardTrackingViewRef>(null);
useEffect(() => {
return () => {
EphemeralStore.setCurrentThreadId('');
};
}, []);
useDidUpdate(() => {
if (!rootPost) {
popTopScreen(componentId);

View File

@@ -24,7 +24,7 @@ type Props = WithDatabaseArgs & {
const enhanced = withObservables(['forceQueryAfterAppState', 'rootPost'], ({database, rootPost}: Props) => {
return {
isCRTEnabled: observeIsCRTEnabled(database),
lastViewedAt: observeMyChannel(database, rootPost.channelId).pipe(
channelLastViewedAt: observeMyChannel(database, rootPost.channelId).pipe(
switchMap((myChannel) => of$(myChannel?.viewedAt)),
),
posts: queryPostsInThread(database, rootPost.id, true, true).observeWithColumns(['earliest', 'latest']).pipe(

View File

@@ -15,8 +15,8 @@ import type PostModel from '@typings/database/models/servers/post';
import type ThreadModel from '@typings/database/models/servers/thread';
type Props = {
channelLastViewedAt: number;
isCRTEnabled: boolean;
lastViewedAt: number;
nativeID: string;
posts: PostModel[];
rootPost: PostModel;
@@ -33,7 +33,7 @@ const styles = StyleSheet.create({
});
const ThreadPostList = ({
isCRTEnabled, lastViewedAt,
channelLastViewedAt, isCRTEnabled,
nativeID, posts, rootPost, teamId, thread,
}: Props) => {
const isTablet = useIsTablet();
@@ -43,7 +43,14 @@ const ThreadPostList = ({
return [...posts, rootPost];
}, [posts, rootPost]);
// If CRT is enabled, When new post arrives and thread modal is open, mark thread as read
// If CRT is enabled, mark the thread as read on mount.
useEffect(() => {
if (isCRTEnabled) {
markThreadAsRead(serverUrl, teamId, rootPost.id);
}
}, []);
// If CRT is enabled, When new post arrives and thread modal is open, mark thread as read.
const oldPostsCount = useRef<number>(posts.length);
useEffect(() => {
if (isCRTEnabled && thread?.isFollowing && oldPostsCount.current < posts.length) {
@@ -52,18 +59,20 @@ const ThreadPostList = ({
}
}, [isCRTEnabled, posts, rootPost, serverUrl, teamId, thread]);
const lastViewedAt = isCRTEnabled ? (thread?.viewedAt ?? 0) : channelLastViewedAt;
const postList = (
<PostList
channelId={rootPost.channelId}
contentContainerStyle={styles.container}
isCRTEnabled={isCRTEnabled}
lastViewedAt={lastViewedAt}
location={Screens.THREAD}
nativeID={nativeID}
posts={threadPosts}
rootId={rootPost.id}
shouldShowJoinLeaveMessages={false}
showMoreMessages={false}
showNewMessageLine={false}
showMoreMessages={isCRTEnabled}
footer={<View style={styles.footer}/>}
testID='thread.post_list'
/>

View File

@@ -18,7 +18,7 @@ class EphemeralStore {
private archivingChannels = new Set<string>();
private convertingChannels = new Set<string>();
private switchingToChannel = new Set<string>();
private lastViewedThreadId = '';
private currentThreadId = '';
// Ephemeral control when (un)archiving a channel locally
addArchivingChannel = (channelId: string) => {
@@ -95,12 +95,12 @@ class EphemeralStore {
};
// Ephemeral for the last viewed thread
getLastViewedThreadId = () => {
return this.lastViewedThreadId;
getCurrentThreadId = () => {
return this.currentThreadId;
};
setLastViewedThreadId = (id: string) => {
this.lastViewedThreadId = id;
setCurrentThreadId = (id: string) => {
this.currentThreadId = id;
};
// Ephemeral control when (un)archiving a channel locally

View File

@@ -18,6 +18,10 @@ type ThreadWithLastFetchedAt = Thread & {
lastFetchedAt: number;
}
type ThreadWithViewedAt = Thread & {
viewed_at: number;
};
type ThreadParticipant = {
id: $ID<User>;
thread_id: $ID<Thread>;