diff --git a/app/actions/local/thread.ts b/app/actions/local/thread.ts index 3c46094fda..99e96c9c39 100644 --- a/app/actions/local/thread.ts +++ b/app/actions/local/thread.ts @@ -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, 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, 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; }); diff --git a/app/actions/remote/thread.ts b/app/actions/remote/thread.ts index 15862278b5..cb565ef8ef 100644 --- a/app/actions/remote/thread.ts +++ b/app/actions/remote/thread.ts @@ -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, }); } diff --git a/app/actions/websocket/threads.ts b/app/actions/websocket/threads.ts index f7c1075122..b695369f77 100644 --- a/app/actions/websocket/threads.ts +++ b/app/actions/websocket/threads.ts @@ -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 { 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 = { 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); } diff --git a/app/components/post_list/more_messages/index.ts b/app/components/post_list/more_messages/index.ts index 7e44043234..ade8c96c6d 100644 --- a/app/components/post_list/more_messages/index.ts +++ b/app/components/post_list/more_messages/index.ts @@ -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))); diff --git a/app/components/post_list/more_messages/more_messages.tsx b/app/components/post_list/more_messages/more_messages.tsx index 667f048157..c1ca8c457f 100644 --- a/app/components/post_list/more_messages/more_messages.tsx +++ b/app/components/post_list/more_messages/more_messages.tsx @@ -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; 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; diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index f805bd0bd4..7ca41201fa 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -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 = (