mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-09 20:50:12 +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 * MM-70905 - disable and grey out the attachment button when an ABAC permission policy denies uploads in the channel * First draft of updated read permission handling * 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 * improved Data fetches * fixes * Fixed test * Fixed archived channel issue * Removed re-sync on team switch * Define message enhancement * Coderabbit fixes * React to permission_policy_updated only for the actions it touched The server now lists the rule actions a permission policy change touched. Every connected client gets this event, so each step runs only when an action it depends on changed: - download_file_attachment raises the redaction epoch, which also revalidates the render decisions - any other cached render-decision action only expires the decisions - channel_read_access reconciles the channel list An empty list (an auto-add toggle) does nothing, and a server that predates the list is treated as having changed every action. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvkCrND4n9oyehQUa9V5QH * Handled empty channel list * Fixed channel access races and tablet kick fallback Read stored channel IDs before the team channel list request so channels joined or left mid-request are not wrongly purged or restored, and set the fallback channel instead of the kicked one as current on a tablet when the server is not active. * Minor fix * Moved pending channel access checks to EphemeralStore * Moved channel access revoked sheet helpers to navigation utils * Gated channel access enforcement on the ChannelPermissionActions feature flag * Reported a channel lost to read access on reconnect as revoked access * Addressed review comments on channel read access Jittered the channel list reconcile after a permission policy update, logged non-request failures in channel access checks as errors, and set the channel read access version to 12.2.0. * Implemented write actions (#10224) * Implemented write actions * Lint fix * Test that write and management policy changes only revalidate decisions A permission_policy_updated event naming channel_write_access or channel_management_access expires the cached render decisions those gates read, without raising the redaction epoch or reconciling the channel list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvkCrND4n9oyehQUa9V5QH * Shared the channel access enforcement check * Addressed review comments on channel write access Hid pinning and the acknowledge button while a write access policy denies the channel, as the server refuses both, and moved the read-only notice to app/components since two screens share it. --------- Co-authored-by: Claude <noreply@anthropic.com> * Expect no Manage button on archived channel members The members screen now renders the Manage header button only when the user can manage and remove members, which is never the case for an archived channel. MM-T1719_1 still expected the button to render and failed on detox-ios and detox-android with "Got: was null". Assert that neither the Manage button nor the remove controls exist instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Fj5x2Znwhwx94X9g76Hny --------- Co-authored-by: Pablo Andrés Vélez Vidal <pablovv2012@gmail.com> Co-authored-by: Claude <noreply@anthropic.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 file 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};
|
|
}
|
|
}
|