forked from Ivasoft/mattermost-mobile
[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:
committed by
GitHub
parent
d8c77855b1
commit
102789bbd9
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
4
types/api/threads.d.ts
vendored
4
types/api/threads.d.ts
vendored
@@ -18,6 +18,10 @@ type ThreadWithLastFetchedAt = Thread & {
|
||||
lastFetchedAt: number;
|
||||
}
|
||||
|
||||
type ThreadWithViewedAt = Thread & {
|
||||
viewed_at: number;
|
||||
};
|
||||
|
||||
type ThreadParticipant = {
|
||||
id: $ID<User>;
|
||||
thread_id: $ID<Thread>;
|
||||
|
||||
Reference in New Issue
Block a user