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 * 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>
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {DeviceEventEmitter} from 'react-native';
|
|
|
|
import {deletePostsForChannelsWithAutotranslation, updateChannelsDisplayName} from '@actions/local/channel';
|
|
import {RedactionInvalidationReason} from '@actions/local/redaction';
|
|
import {setCurrentUserStatus} from '@actions/local/user';
|
|
import {fetchMe, fetchUsersByIds} from '@actions/remote/user';
|
|
import {invalidateRedactionForCurrentUser} from '@actions/websocket/access_control';
|
|
import {General, Events, Preferences, WebsocketEvents} from '@constants';
|
|
import {SESSION_ATTRIBUTES_OBJECT_TYPE, SESSION_ATTRIBUTES_PLATFORM_MOBILE} from '@constants/session_attributes';
|
|
import DatabaseManager from '@database/manager';
|
|
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
|
|
import SessionAttributesManager from '@managers/session_attributes_manager';
|
|
import WebsocketManager from '@managers/websocket_manager';
|
|
import {queryChannelsByTypes, queryUserChannelsByTypes} from '@queries/servers/channel';
|
|
import {queryDisplayNamePreferences} from '@queries/servers/preference';
|
|
import {getConfig, getLicense} from '@queries/servers/system';
|
|
import {getCurrentUser} from '@queries/servers/user';
|
|
import {customProfileAttributeId} from '@utils/custom_profile_attribute';
|
|
import {getFullErrorMessage} from '@utils/errors';
|
|
import {safeParseJSON} from '@utils/helpers';
|
|
import {logError} from '@utils/log';
|
|
import {displayUsername, haveSameRoles} from '@utils/user';
|
|
|
|
import type {Model} from '@nozbe/watermelondb';
|
|
import type {CustomProfileField} from '@typings/api/custom_profile_attributes';
|
|
|
|
export async function handleUserUpdatedEvent(serverUrl: string, msg: WebSocketMessage) {
|
|
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
|
if (!operator) {
|
|
return;
|
|
}
|
|
|
|
const {database} = operator;
|
|
const currentUser = await getCurrentUser(database);
|
|
if (!currentUser) {
|
|
return;
|
|
}
|
|
|
|
const user: UserProfile = msg.data.user;
|
|
const modelsToBatch: Model[] = [];
|
|
let userToSave = user;
|
|
|
|
if (user.id === currentUser.id) {
|
|
if (user.update_at > (currentUser?.updateAt || 0)) {
|
|
// ESR: 6.5
|
|
if (!user.notify_props || !Object.keys(user.notify_props).length) {
|
|
// Current user is sanitized so we fetch it from the server
|
|
// Need to request me to make sure we don't override with sanitized fields from the
|
|
// websocket event
|
|
const me = await fetchMe(serverUrl, true);
|
|
if (me.user) {
|
|
userToSave = me.user;
|
|
}
|
|
}
|
|
|
|
// Update GMs display name if locale has changed
|
|
if (user.locale !== currentUser.locale) {
|
|
const channels = await queryChannelsByTypes(database, [General.GM_CHANNEL]).fetch();
|
|
if (channels.length) {
|
|
const {models} = await updateChannelsDisplayName(serverUrl, channels, [user], true);
|
|
if (models?.length) {
|
|
modelsToBatch.push(...models);
|
|
}
|
|
}
|
|
|
|
// Delete posts for all channels with user autotranslation enabled when locale changes
|
|
await deletePostsForChannelsWithAutotranslation(serverUrl);
|
|
}
|
|
}
|
|
} else {
|
|
const channels = await queryUserChannelsByTypes(database, user.id, [General.DM_CHANNEL, General.GM_CHANNEL]).fetch();
|
|
if (channels.length) {
|
|
const {models} = await updateChannelsDisplayName(serverUrl, channels, [user], true);
|
|
if (models?.length) {
|
|
modelsToBatch.push(...models);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (user.id === currentUser.id) {
|
|
// BuildAccessControlSubject reads these native fields, so a change to one can flip a
|
|
// decision; cosmetic edits must not, or every nickname change invalidates the cache.
|
|
// email_verified and create_at are in the subject too but are not persisted locally.
|
|
const abacFieldChanged =
|
|
userToSave.email !== currentUser.email ||
|
|
Boolean(userToSave.is_bot) !== Boolean(currentUser.isBot) ||
|
|
!haveSameRoles(currentUser.roles, userToSave.roles);
|
|
|
|
if (abacFieldChanged) {
|
|
invalidateRedactionForCurrentUser(serverUrl, RedactionInvalidationReason.UserFields);
|
|
}
|
|
}
|
|
|
|
const userModel = await operator.handleUsers({users: [userToSave], prepareRecordsOnly: true});
|
|
modelsToBatch.push(...userModel);
|
|
|
|
try {
|
|
await operator.batchRecords(modelsToBatch, 'handleUserUpdatedEvent');
|
|
} catch {
|
|
// do nothing
|
|
}
|
|
}
|
|
|
|
export async function handleUserTypingEvent(serverUrl: string, msg: WebSocketMessage) {
|
|
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
|
|
if (currentServerUrl === serverUrl) {
|
|
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
|
if (!database) {
|
|
return;
|
|
}
|
|
|
|
const license = await getLicense(database);
|
|
const config = await getConfig(database);
|
|
|
|
const {users, existingUsers} = await fetchUsersByIds(serverUrl, [msg.data.user_id]);
|
|
const user = users?.[0] || existingUsers?.[0];
|
|
|
|
const namePreference = await queryDisplayNamePreferences(database, Preferences.NAME_NAME_FORMAT)?.fetch();
|
|
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(namePreference, config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
|
|
const currentUser = await getCurrentUser(database);
|
|
const username = displayUsername(user, currentUser?.locale, teammateDisplayNameSetting);
|
|
const data = {
|
|
channelId: msg.broadcast.channel_id,
|
|
rootId: msg.data.parent_id,
|
|
userId: msg.data.user_id,
|
|
username,
|
|
now: Date.now(),
|
|
};
|
|
DeviceEventEmitter.emit(Events.USER_TYPING, data);
|
|
|
|
setTimeout(() => {
|
|
DeviceEventEmitter.emit(Events.USER_STOP_TYPING, data);
|
|
}, parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds, 10));
|
|
}
|
|
}
|
|
|
|
export const userTyping = async (serverUrl: string, channelId: string, rootId?: string) => {
|
|
const client = WebsocketManager.getClient(serverUrl);
|
|
client?.sendUserTypingEvent(channelId, rootId);
|
|
};
|
|
|
|
export async function handleStatusChangedEvent(serverUrl: string, msg: WebSocketMessage) {
|
|
const newStatus = msg.data.status;
|
|
setCurrentUserStatus(serverUrl, newStatus);
|
|
}
|
|
|
|
export async function handleCustomProfileAttributesValuesUpdatedEvent(serverUrl: string, msg: WebSocketMessage) {
|
|
try {
|
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
|
|
const {user_id, values} = msg.data;
|
|
const attributesForDatabase = Object.entries(values).map(([fieldId, value]) => ({
|
|
id: customProfileAttributeId(fieldId, user_id),
|
|
field_id: fieldId,
|
|
user_id,
|
|
value: value as string,
|
|
}));
|
|
|
|
try {
|
|
await operator.handleCustomProfileAttributes({
|
|
attributes: attributesForDatabase,
|
|
prepareRecordsOnly: false,
|
|
});
|
|
} catch (error) {
|
|
logError('Error handling custom profile attributes values updated event', error);
|
|
}
|
|
|
|
// The user's own attributes feed every policy on this server, so the epoch is raised for all
|
|
// channels; only visible ones refetch now, the rest converge when rendered.
|
|
const currentUser = await getCurrentUser(database);
|
|
if (currentUser?.id === user_id) {
|
|
invalidateRedactionForCurrentUser(serverUrl, RedactionInvalidationReason.UserAttributes, true);
|
|
}
|
|
} catch (error) {
|
|
logError('Error getting the operator for the custom profile attributes values updated event', error);
|
|
}
|
|
}
|
|
|
|
export async function handleCustomProfileAttributesFieldUpdatedEvent(serverUrl: string, msg: WebSocketMessage) {
|
|
try {
|
|
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
|
|
const {field} = msg.data;
|
|
|
|
try {
|
|
await operator.handleCustomProfileFields({
|
|
fields: [field],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
} catch (error) {
|
|
logError('Error handling custom profile attributes field updated event', error);
|
|
}
|
|
|
|
// Renaming an option or changing a field's type rewrites what every policy is evaluated
|
|
// against without a values event. A new field has no values yet, so it cannot.
|
|
if (msg.event === WebsocketEvents.CUSTOM_PROFILE_ATTRIBUTES_FIELD_UPDATED) {
|
|
invalidateRedactionForCurrentUser(serverUrl, RedactionInvalidationReason.UserAttributes, true);
|
|
}
|
|
} catch (error) {
|
|
logError('Error getting the operator for the custom profile field updated event', error);
|
|
}
|
|
|
|
}
|
|
|
|
export async function handleCustomProfileAttributesFieldDeletedEvent(serverUrl: string, msg: WebSocketMessage) {
|
|
try {
|
|
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
|
|
const {field_id} = msg.data;
|
|
|
|
try {
|
|
// Delete the field from the database
|
|
const fieldForDatabase: CustomProfileField = {
|
|
id: field_id,
|
|
group_id: '',
|
|
name: '',
|
|
type: '',
|
|
target_id: '',
|
|
target_type: '',
|
|
create_at: 0,
|
|
update_at: 0,
|
|
delete_at: Date.now(),
|
|
attrs: {},
|
|
};
|
|
|
|
await operator.handleCustomProfileFields({
|
|
fields: [fieldForDatabase],
|
|
prepareRecordsOnly: false,
|
|
});
|
|
} catch (error) {
|
|
logError('Error handling custom profile field deleted event', error);
|
|
}
|
|
|
|
// Deleting a field drops its values for every user at once, so the subject can change with
|
|
// no per-user value event.
|
|
invalidateRedactionForCurrentUser(serverUrl, RedactionInvalidationReason.UserAttributes, true);
|
|
} catch (error) {
|
|
logError('Error getting the operator for the custom profile field deleted event', error);
|
|
}
|
|
}
|
|
|
|
export function handleSessionAttributesPropertyFieldEvent(serverUrl: string, msg: WebSocketMessage) {
|
|
try {
|
|
const {object_type, property_field} = msg.data;
|
|
if (object_type !== SESSION_ATTRIBUTES_OBJECT_TYPE) {
|
|
return;
|
|
}
|
|
|
|
const field = safeParseJSON(property_field) as SAPropertyField;
|
|
if (!field?.name || !field.attrs) {
|
|
return;
|
|
}
|
|
if (!Boolean(field.attrs.enabled) || !Boolean(field.attrs.platforms.includes(SESSION_ATTRIBUTES_PLATFORM_MOBILE))) {
|
|
SessionAttributesManager.removeManifestField(serverUrl, field.name);
|
|
return;
|
|
}
|
|
|
|
SessionAttributesManager.upsertManifestField(serverUrl, {
|
|
name: field.name,
|
|
type: field.type,
|
|
ttl_seconds: field.attrs.ttl_seconds,
|
|
grace_period_seconds: field.attrs.grace_period_seconds,
|
|
});
|
|
} catch (error) {
|
|
logError('[handleSessionAttributesPropertyFieldEvent]', getFullErrorMessage(error));
|
|
}
|
|
}
|