mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-13 06:30:29 +00:00
* 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>
326 lines
13 KiB
TypeScript
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};
|
|
}
|
|
}
|