mattermost-mobile-upstream/app/actions/websocket/users.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

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));
}
}