mattermost-mobile/app/actions/local/thread.ts
Rajat Dabade e39b27be7e
New keyboard library Setup (#9278)
* Initial setup for new keyboard

* fix the offset calculation onMove by adding isKeyboardFullyOpened

* Done with the keyboard handling implementation

* Handled keyboard focus and blured state using context

* Added default height for input container

* Android support

* Tablet state handling

* Fix for refreshing offset in list

* Created a default context for mention post list

* Fix linter errors

* Fix tests

* Minor

* Fix the height issue for tablet view

* Review comments

* Dependency fix

* Reveiw comment

* keyboard animation only enabled with screen on top navigation

* added tests

* Added extra keyboard component for emoji picker (#9328)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Check for stale event for keyboard geature area

* fix keyboard stale event for mid-gesture change swipe direction

* Closing emoji picker when message priority is opened

* changing to thread view closes emoji picker

* Close emoij picker and keyboard when attachment icons are clicked

* handle android emoji picker behaviour

* Remove emoji picker code

* fix tests

* Address reviev comments

* Test fixes

* usePreventDoubleTab for race condition and corrected comment on isInputAccessoryViewMode flag

* File attachments option in bottom sheet quick action (#9331)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* File attachments option in bottom sheet quick action

* Updated tests

* i18n

* fix test

* Added tests

* Review comment

* fix tests

* Integrated Emoji picker to Extra keyboard component.  (#9339)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Fix the post input container height change issue

* Added emoji picker with search functionality

* keyboard height not recorded fallback to default height, set search to false closing emoji picker

* fix search funcitonality in android

* scroll to end bottom dismissed with pressed

* Fixed the scroll issue for android

* fix the flickering post input issue

* Wired up the emoji picker

* Added keyboard and spaceback in emoji picker

* intl extract

* initial cursor position and simple calculation review comment

* separated grapheme to utils file

* Review comments

* nitpick

* Fix the bottom safe area and navigation stack restore fix

* Fix test

* disabled emoji picker in edit post screen

* change the name of the variable to name it clarier

* fix the tab gap issue in andriod

* disabled the emoji skin tone change on andriod

* fix the input container jump issue

* fix the failing test

* UX review

* added white space after the attachment icon

* When @ or / is press open keyboard on andriod also fix the scroll issue when keyboard is open

* back bottom closes emoji picker and opening keyboard jump fix

* reverted code

* fixed the flickering issue of input and scroll position fix

* Fix the gap issue in thread

* fix the warning reaminated issue when opening a channel

* Fix the extra space issue between input and emoij picker

* Minor fix for extra space between input and emoji picker

* Fix thread view bottom safe area and gap between keyboard and searched emojis

* Fix the keyboard issue in tablet

* Fix the warning issue react-native-keyboard-controller

* Fix tests

* Fix the tablet search hight issue

* Replaced the ExtraKeyboardProvider with KeyboardProvider from rnkc for permalink

* Bottom sheet jump issue resolved

* Search do not close after selecting emoji in search list

* Added the bottom inset height in search emoji for android

* fix buid issue

* Fix the cancel state to emoji picker

* use portals for autocomplete

* fix permalink extra space in post list

* Portal only for android

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-13 22:38:35 +05:30

345 lines
13 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {defineMessages} from 'react-intl';
import {DeviceEventEmitter} from 'react-native';
import {ActionType, Events, General, Navigation, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {getTranslations} from '@i18n';
import {getChannelById} from '@queries/servers/channel';
import {getPostById} from '@queries/servers/post';
import {getCurrentTeamId, getCurrentUserId, prepareCommonSystemValues, type PrepareCommonSystemValuesArgs, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {addChannelToTeamHistory, addTeamToTeamHistory} from '@queries/servers/team';
import {getThreadById, prepareThreadsFromReceivedPosts, queryThreadsInTeam} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
import {dismissAllModals, dismissAllModalsAndPopToRoot, dismissAllOverlays, goToScreen} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {isTablet} from '@utils/helpers';
import {logError} from '@utils/log';
import {changeOpacity} from '@utils/theme';
import type Model from '@nozbe/watermelondb/Model';
export const switchToGlobalThreads = async (serverUrl: string, teamId?: string, prepareRecordsOnly = false) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models: Model[] = [];
let teamIdToUse = teamId;
if (!teamId) {
teamIdToUse = await getCurrentTeamId(database);
}
if (!teamIdToUse) {
throw new Error('no team to switch to');
}
await setCurrentTeamAndChannelId(operator, teamIdToUse, '');
const history = await addChannelToTeamHistory(operator, teamIdToUse, Screens.GLOBAL_THREADS, true);
models.push(...history);
if (!prepareRecordsOnly) {
await operator.batchRecords(models, 'switchToGlobalThreads');
}
const isTabletDevice = isTablet();
if (isTabletDevice) {
DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_THREADS);
} else {
goToScreen(Screens.GLOBAL_THREADS, '', {}, {topBar: {visible: false}});
}
return {models};
} catch (error) {
logError('Failed switchToGlobalThreads', error);
return {error};
}
};
const threadMessages = defineMessages({
thread: {
id: 'thread.header.thread',
defaultMessage: 'Thread',
},
threadIn: {
id: 'thread.header.thread_in',
defaultMessage: 'in {channelName}',
},
});
export const switchToThread = async (serverUrl: string, rootId: string, isFromNotification = false) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const user = await getCurrentUser(database);
if (!user) {
throw new Error('User not found');
}
const post = await getPostById(database, rootId);
if (!post) {
throw new Error('Post not found');
}
const channel = await getChannelById(database, post.channelId);
if (!channel) {
throw new Error('Channel not found');
}
const currentTeamId = await getCurrentTeamId(database);
const isTabletDevice = isTablet();
const teamId = channel.teamId || currentTeamId;
const currentThreadId = EphemeralStore.getCurrentThreadId();
EphemeralStore.setCurrentThreadId(rootId);
if (isFromNotification) {
if (currentThreadId && currentThreadId === rootId && NavigationStore.getScreensInStack().includes(Screens.THREAD)) {
await dismissAllModals();
await dismissAllOverlays();
return {};
}
await dismissAllModalsAndPopToRoot();
await NavigationStore.waitUntilScreenIsTop(Screens.HOME);
if (currentTeamId !== teamId && isTabletDevice) {
DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_THREADS);
}
}
if (currentTeamId !== teamId) {
const modelPromises: Array<Promise<Model[]>> = [];
modelPromises.push(addTeamToTeamHistory(operator, teamId, true));
const commonValues: PrepareCommonSystemValuesArgs = {
currentChannelId: channel.id,
currentTeamId: teamId,
};
modelPromises.push(prepareCommonSystemValues(operator, commonValues));
const models = (await Promise.all(modelPromises)).flat();
if (models.length) {
await operator.batchRecords(models, 'switchToThread');
}
}
// Get translation by user locale
const translations = getTranslations(user.locale);
// Get title translation or default title message
const title = translations[threadMessages.thread.id] || 'Thread';
let subtitle = '';
if (channel?.type === General.DM_CHANNEL) {
subtitle = channel.displayName;
} else {
// Get translation or default message
subtitle = translations[threadMessages.threadIn.id] || 'in {channelName}';
subtitle = subtitle.replace('{channelName}', channel.displayName);
}
DeviceEventEmitter.emit(Events.CLOSE_INPUT_ACCESSORY_VIEW);
goToScreen(Screens.THREAD, '', {rootId}, {
topBar: {
title: {
text: title,
},
subtitle: {
color: changeOpacity(EphemeralStore.theme!.sidebarHeaderTextColor, 0.72),
text: subtitle,
},
noBorder: true,
scrollEdgeAppearance: {
noBorder: true,
active: true,
},
},
});
return {};
} catch (error) {
logError('Failed switchToThread', error);
EphemeralStore.setCurrentThreadId('');
return {error};
}
};
// When new post arrives:
// 1. If a reply, then update the reply_count, add user as the participant
// 2. Else add the post as a thread
export async function createThreadFromNewPost(serverUrl: string, post: Post, prepareRecordsOnly = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models: Model[] = [];
if (post.root_id) {
// Update the thread data: `reply_count`
const {model: threadModel} = await updateThread(serverUrl, post.root_id, {reply_count: post.reply_count}, true);
if (threadModel) {
models.push(threadModel);
}
// Add user as a participant to the thread
const threadParticipantModels = await operator.handleThreadParticipants({
threadsParticipants: [{
thread_id: post.root_id,
participants: [{
thread_id: post.root_id,
id: post.user_id,
}],
}],
prepareRecordsOnly: true,
skipSync: true,
});
models.push(...threadParticipantModels);
} else { // If the post is a root post, then we need to add it to the thread table
const threadModels = await prepareThreadsFromReceivedPosts(operator, [post], false);
models.push(...threadModels);
}
if (!prepareRecordsOnly) {
await operator.batchRecords(models, 'createThreadFromNewPost');
}
return {models};
} catch (error) {
logError('Failed createThreadFromNewPost', error);
return {error};
}
}
// On receiving threads, Along with the "threads" & "thread participants", extract and save "posts" & "users"
export async function processReceivedThreads(serverUrl: string, threads: Thread[], teamId: string, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentUserId = await getCurrentUserId(database);
const posts: Post[] = [];
const users: UserProfile[] = [];
const threadsToHandle: ThreadWithLastFetchedAt[] = [];
// Extract posts & users from the received threads
for (let i = 0; i < threads.length; i++) {
const {participants, post} = threads[i];
posts.push(post);
participants.forEach((participant) => {
if (currentUserId !== participant.id) {
users.push(participant);
}
});
threadsToHandle.push({...threads[i], lastFetchedAt: post.create_at});
}
const postModels = await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [],
posts,
prepareRecordsOnly: true,
});
const threadModels = await operator.handleThreads({
threads: threadsToHandle,
teamId,
prepareRecordsOnly: true,
});
const models = [...postModels, ...threadModels];
if (users.length) {
const userModels = await operator.handleUsers({
users,
prepareRecordsOnly: true,
});
models.push(...userModels);
}
if (!prepareRecordsOnly) {
await operator.batchRecords(models, 'processReceivedThreads');
}
return {models};
} catch (error) {
logError('Failed processReceivedThreads', error);
return {error};
}
}
export async function markTeamThreadsAsRead(serverUrl: string, teamId: string, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const threads = await queryThreadsInTeam(database, teamId, {onlyUnreads: true, hasReplies: true, isFollowing: true}).fetch();
const models = threads.map((thread) => thread.prepareUpdate((record) => {
record.unreadMentions = 0;
record.unreadReplies = 0;
record.lastViewedAt = Date.now();
record.viewedAt = Date.now();
}));
if (!prepareRecordsOnly) {
await operator.batchRecords(models, 'markTeamThreadsAsRead');
}
return {models};
} catch (error) {
logError('Failed markTeamThreadsAsRead', error);
return {error};
}
}
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], 'markThreadAsViewed');
}
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);
if (!thread) {
throw new Error('Thread not found');
}
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;
});
if (!prepareRecordsOnly) {
await operator.batchRecords([model], 'updateThread');
}
return {model};
} catch (error) {
logError('Failed updateThread', error);
return {error};
}
}
export async function updateTeamThreadsSync(serverUrl: string, data: TeamThreadsSync, prepareRecordsOnly = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models = await operator.handleTeamThreadsSync({data: [data], prepareRecordsOnly});
if (!prepareRecordsOnly) {
await operator.batchRecords(models, 'updateTeamThreadsSync');
}
return {models};
} catch (error) {
logError('Failed updateTeamThreadsSync', error);
return {error};
}
}