mattermost-mobile-upstream/app/actions/local/thread.ts
Pablo Vélez 7df4a0ab97
Some checks are pending
ci / lint-typecheck (push) Waiting to run
ci / detox-lint-typecheck (push) Waiting to run
ci / test (push) Waiting to run
Mm 68219 show placeholder for redacted files (#9719)
* MM-68219 - Add redacted files placeholder and related functionality

* add feature flag control and e2e tests

* adjust placeholder design, polish code, fix permalink preview issue

* polish code and add missing test

* use import path with right path

* gate post refetch behind the feature flag, refetch the open thread and avoid observeCount

* restore filesInfo for the permalink files prop added upstream

* fix stale redaction on background servers, consume the flag on every fetch path and share the viewport emitter

* replace the in-memory redaction flag with a persisted per-post epoch so ABAC file decisions survive restarts and stale posts stay hidden

* raise the redaction epoch on every resync, capture the epoch counter, only invalidate channels on role changes, and store the newer epoch when a re-verified post is otherwise unchanged

* store superseded responses unverified instead of dropping them, keep the since-fetch when re-verifying, verify permalink embeds against the linked channel, and capture the websocket epoch on arrival

* nvalidate file decisions when a user attribute field changes or all its values are cleared

* batch channel invalidations, close file viewers only when affected, reset the epoch on unstamped redaction changes, and only  re-verify the newest page's attachment posts

* re-check older channel posts a page at a time instead of one request per post

* skip the unstamped epoch lookup for posts with no files, redacted count or permalink

* keep the attribute-view retry after later invalidations, retry linked posts unless refused for the current epoch, and stop older payloads from rolling back edits via the redaction check

* MM-70905 - disable and grey out the attachment button when an ABAC pe… (#10195)

* MM-70905 - disable and grey out the attachment button when an ABAC permission policy denies uploads in the channel

* only back off render permission fetches when the server request fails, and log other errors without retrying

* remove the disableServer helper and have removeServer do both steps

* split defaultAllowed into allowWhenUnenforced and allowWhenNotEvaluated, and treat render permissions as unenforced  on servers without the decisions API

* MM-69627: Add playbook task requirements support on mobile (#10035)

* MM-69627: Add playbook task requirements support on mobile

Allow filling required task fields when checking off checklist items during
a run, gated by the same Playbooks beta setting used on web.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Address PR review feedback for task requirements

Propagate settings write failures, harden fill-requirements save/title handling, and add missing unit coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Fix TypeScript errors in new unit tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Use defineMessages for fill requirements titles

Keep i18n extraction able to find the header titles after moving them to the route.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Address review feedback for task requirements

Update checklist items locally when saving requirement values instead of
refetching the run, add missing query/navigation tests, and apply small
callback/reduce cleanups from review.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Fix task requirements review follow-ups

Propagate local checklist write failures, clear a stale task-requirements
flag when playbooks is unsupported, and cap fill values at the desktop/
server max length.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Move requirement field into its own component

Keep one component per file on the fill-requirements screen.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69122: Add file element support to interactive dialogs (#9898)

* Implement Interactive Dialog - file upload

* update demo plugin version for e2etests

* fix and add e2etests

* review fixes

* Fix version bump checkout when a tag shares the branch name (#10229)

* Fix Android crashes caused by animated custom emoji (#10030)

* Route GIF decoding through Glide

- Keep APNG4Android for APNG, animated WebP, and animated AVIF support.
- Prepend Glide's GIF decoders to avoid APNG4Android's native GIF overflow.

* Clear Expo image resources with Glide targets

- Detach drawables when Glide clears their custom target.
- Prevent recycled GIF frame resources from remaining attached to Expo image views.

* Preserve foreground image during GIF target recycling

---------

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com>
Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com>

* detox - fix clobbering of feature flags that cause failures in other suites.  Also small fix of a incorrectly set test-id

---------

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com>
Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com>
Co-authored-by: Nate Schlossberg <nate.schlossberg@mattermost.com>
2026-10-15 15:59:13 -07:00

326 lines
13 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DeviceEventEmitter} from 'react-native';
import {ActionType, Events, Navigation, Screens} from '@constants';
import DatabaseManager from '@database/manager';
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 {navigateToScreen, dismissToStackRoot, navigateToRoot} 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 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 {
await NavigationStore.waitUntilScreenHasLoaded(Screens.HOME);
navigateToScreen(Screens.GLOBAL_THREADS);
}
return {models};
} catch (error) {
logError('Failed switchToGlobalThreads', error);
return {error};
}
};
export const switchToThread = async (serverUrl: string, rootId: string, isFromNotification = false) => {
DeviceEventEmitter.emit(Events.BLUR_AND_DISMISS_KEYBOARD);
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 dismissToStackRoot();
return {};
}
await navigateToRoot();
await NavigationStore.waitUntilScreenIsTop(Screens.CHANNEL_LIST);
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');
}
}
await NavigationStore.waitUntilScreenHasLoaded(Screens.CHANNEL_LIST);
navigateToScreen(Screens.THREAD, {rootId, channelName: channel.displayName});
DeviceEventEmitter.emit(Events.CLOSE_INPUT_ACCESSORY_VIEW);
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 {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models: Model[] = [];
if (post.root_id) {
const existingThread = await getThreadById(database, post.root_id);
if (existingThread) {
// Update the thread data: `reply_count` and auto-follow (replying auto-follows the thread)
const {model: threadModel} = await updateThread(serverUrl, post.root_id, {
reply_count: post.reply_count,
is_following: true,
}, true);
if (threadModel) {
models.push(threadModel);
}
} else {
const rootPost = await getPostById(database, post.root_id);
if (rootPost) {
const threadModels = await prepareThreadsFromReceivedPosts(operator, [{
...await rootPost.toApi(),
reply_count: post.reply_count,
is_following: true,
}], false);
models.push(...threadModels);
}
}
// 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];
if (post) {
posts.push(post);
}
if (participants) {
participants.forEach((participant) => {
if (currentUserId !== participant.id) {
users.push(participant);
}
});
}
threadsToHandle.push({...threads[i], lastFetchedAt: post?.create_at ?? 0});
}
// Deliberately unstamped: thread payloads (REST and thread_updated alike) carry the root post
// without metadata, so they say nothing about its attachments and must not vouch for them.
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};
}
}