mattermost-mobile-upstream/app/utils/post/index.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

398 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Alert, type AlertButton} from 'react-native';
import {getUsersCountFromMentions} from '@actions/local/post';
import {General, Post} from '@constants';
import {SPECIAL_MENTIONS_REGEX} from '@constants/autocomplete';
import {POST_TIME_TO_FAIL} from '@constants/post';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE} from '@i18n';
import {getUserById} from '@queries/servers/user';
import {toMilliseconds} from '@utils/datetime';
import {ensureString, includes} from '@utils/types';
import {displayUsername, getUserIdFromChannelName} from '@utils/user';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
import type {IntlShape} from 'react-intl';
export function hasAiGeneratedMetadata(post: PostModel | Post): boolean {
return Boolean(ensureString(post.props?.ai_generated_by) && ensureString(post.props?.ai_generated_by_username));
}
export function areConsecutivePosts(post: PostModel, previousPost: PostModel, userLocale: string) {
// Caller already excludes AI current posts; break after an AI previous post so the next header shows.
if (hasAiGeneratedMetadata(previousPost)) {
return false;
}
let consecutive = false;
if (post && previousPost) {
const postFromWebhook = Boolean(post?.props?.from_webhook); // eslint-disable-line camelcase
const prevPostFromWebhook = Boolean(previousPost?.props?.from_webhook); // eslint-disable-line camelcase
const isFromSameUser = previousPost.userId === post.userId;
const isNotSystemMessage = !isSystemMessage(post) && !isSystemMessage(previousPost);
const isInTimeframe = (post.createAt - previousPost.createAt) <= Post.POST_COLLAPSE_TIMEOUT;
// Were the last post and this post made by the same user within some time?
consecutive = previousPost && isFromSameUser && isInTimeframe && !postFromWebhook &&
!prevPostFromWebhook && isNotSystemMessage;
}
if (consecutive) {
const postTranslation = getPostTranslation(post, userLocale);
const previousPostTranslation = getPostTranslation(previousPost, userLocale);
if (postTranslation?.state !== previousPostTranslation?.state) {
consecutive = false;
}
}
return consecutive;
}
export function isFromWebhook(post: PostModel | Post): boolean {
return post.props?.from_webhook === 'true';
}
export function isFromBot(post: PostModel | Post): boolean {
return post.props?.from_bot === 'true';
}
export function isFromPlugin(post: PostModel | Post): boolean {
return post.props?.from_plugin === 'true';
}
export function isEdited(post: PostModel): boolean {
return post.editAt > 0;
}
export function isPostEphemeral(post: PostModel): boolean {
return post.type === Post.POST_TYPES.EPHEMERAL || post.type === Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL || post.deleteAt > 0;
}
type EphemeralIdentityFields = {
create_at?: number;
root_id?: string;
user_id?: string;
};
// Integration update payloads are partial posts (often create_at=0). Preserve identity from the stored post.
export function restoreEphemeralIdentityFieldsForEdit(incoming: Post, stored: EphemeralIdentityFields): Post {
const out: Post = {...incoming};
if (!out.create_at && stored.create_at) {
out.create_at = stored.create_at;
}
if (!out.root_id && stored.root_id) {
out.root_id = stored.root_id;
}
if (!out.user_id && stored.user_id) {
out.user_id = stored.user_id;
}
return out;
}
export function isPostFailed(post: PostModel): boolean {
return Boolean(post.props?.failed) || ((post.pendingPostId === post.id) && (Date.now() > post.updateAt + POST_TIME_TO_FAIL));
}
export function isPostPendingOrFailed(post: PostModel): boolean {
return post.pendingPostId === post.id || isPostFailed(post);
}
export function isSystemMessage(post: PostModel | Post): boolean {
return Boolean(post.type && post.type?.startsWith(Post.POST_TYPES.SYSTEM_MESSAGE_PREFIX));
}
export function fromAutoResponder(post: PostModel): boolean {
return Boolean(post.type && (post.type === Post.POST_TYPES.SYSTEM_AUTO_RESPONDER));
}
export function postUserDisplayName(post: PostModel, author?: UserModel, teammateNameDisplay?: string, enablePostUsernameOverride = false) {
const overrideUsername = ensureString(post.props?.override_username);
if (
isFromWebhook(post) &&
enablePostUsernameOverride &&
overrideUsername
) {
return overrideUsername;
}
return displayUsername(author, author?.locale || DEFAULT_LOCALE, teammateNameDisplay, true);
}
export function shouldIgnorePost(post: Post): boolean {
return includes(Post.IGNORE_POST_TYPES, post.type);
}
export const processPostsFetched = (data: PostResponse) => {
const order = data.order;
const posts = Object.values(data.posts);
const previousPostId = data.prev_post_id;
return {
posts,
order,
previousPostId,
};
};
export const getLastFetchedAtFromPosts = (posts?: Post[]) => {
return posts?.reduce((timestamp: number, p) => {
const maxTimestamp = Math.max(p.create_at, p.update_at, p.delete_at);
return Math.max(maxTimestamp, timestamp);
}, 0) || 0;
};
export const moreThan5minAgo = (time: number) => {
return Date.now() - time > toMilliseconds({minutes: 5});
};
export function hasSpecialMentions(message: string): boolean {
const result = SPECIAL_MENTIONS_REGEX.test(message);
// https://stackoverflow.com/questions/1520800/why-does-a-regexp-with-global-flag-give-wrong-results
SPECIAL_MENTIONS_REGEX.lastIndex = 0;
return result;
}
export async function persistentNotificationsConfirmation(serverUrl: string, value: string, mentionsList: string[], intl: IntlShape, sendMessage: () => void, persistentNotificationMaxRecipients: number, persistentNotificationInterval: number, currentUserId: string, channelName?: string, channelType?: ChannelType) {
let title = '';
let description = '';
let buttons: AlertButton[] = [{
text: intl.formatMessage({
id: 'persistent_notifications.error.okay',
defaultMessage: 'Okay',
}),
style: 'cancel',
}];
if (channelType === General.DM_CHANNEL) {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const teammateId = getUserIdFromChannelName(currentUserId, channelName!);
const user = await getUserById(database, teammateId);
title = intl.formatMessage({
id: 'persistent_notifications.confirm.title',
defaultMessage: 'Send persistent notifications',
});
description = intl.formatMessage({
id: 'persistent_notifications.dm_channel.description',
defaultMessage: '{username} will be notified every {interval, plural, one {minute} other {{interval} minutes}} until they’ve acknowledged or replied to the message.',
}, {
interval: persistentNotificationInterval,
username: user?.username,
});
buttons = [{
text: intl.formatMessage({
id: 'persistent_notifications.confirm.cancel',
defaultMessage: 'Cancel',
}),
style: 'cancel',
},
{
text: intl.formatMessage({
id: 'persistent_notifications.confirm.send',
defaultMessage: 'Send',
}),
onPress: sendMessage,
}];
} else if (hasSpecialMentions(value)) {
description = intl.formatMessage({
id: 'persistent_notifications.error.special_mentions',
defaultMessage: 'Cannot use @channel, @all or @here to mention recipients of persistent notifications.',
});
} else {
// removes the @ from the mention
const formattedMentionsList = mentionsList.map((mention) => mention.slice(1));
const usersCount = await getUsersCountFromMentions(serverUrl, formattedMentionsList);
if (usersCount === 0) {
title = intl.formatMessage({
id: 'persistent_notifications.error.no_mentions.title',
defaultMessage: 'Recipients must be @mentioned',
});
description = intl.formatMessage({
id: 'persistent_notifications.error.no_mentions.description',
defaultMessage: 'There are no recipients mentioned in your message. You’ll need add mentions to be able to send persistent notifications.',
});
} else if (usersCount > persistentNotificationMaxRecipients) {
title = intl.formatMessage({
id: 'persistent_notifications.error.max_recipients.title',
defaultMessage: 'Too many recipients',
});
description = intl.formatMessage({
id: 'persistent_notifications.error.max_recipients.description',
defaultMessage: 'You can send persistent notifications to a maximum of {max} recipients. There are {count} recipients mentioned in your message. You’ll need to change who you’ve mentioned before you can send.',
}, {
max: persistentNotificationMaxRecipients,
count: mentionsList.length,
});
} else {
title = intl.formatMessage({
id: 'persistent_notifications.confirm.title',
defaultMessage: 'Send persistent notifications',
});
description = intl.formatMessage({
id: 'persistent_notifications.confirm.description',
defaultMessage: 'Mentioned recipients will be notified every {interval, plural, one {minute} other {{interval} minutes}} until they’ve acknowledged or replied to the message.',
}, {
interval: persistentNotificationInterval,
});
buttons = [{
text: intl.formatMessage({
id: 'persistent_notifications.confirm.cancel',
defaultMessage: 'Cancel',
}),
style: 'cancel',
},
{
text: intl.formatMessage({
id: 'persistent_notifications.confirm.send',
defaultMessage: 'Send',
}),
onPress: sendMessage,
}];
}
}
Alert.alert(
title,
description,
buttons,
);
}
export async function sendMessageWithAlert({title, channelName, intl, sendMessageHandler}: {
title: string;
channelName: string;
intl: IntlShape;
sendMessageHandler: () => void;
}) {
const buttons: AlertButton[] = [{
text: intl.formatMessage({
id: 'send_message.confirm.cancel',
defaultMessage: 'Cancel',
}),
style: 'cancel',
}, {
text: intl.formatMessage({
id: 'send_message.confirm.send',
defaultMessage: 'Send',
}),
onPress: sendMessageHandler,
}];
const description = intl.formatMessage({
id: 'send_message.confirm.description',
defaultMessage: 'Are you sure you want to send this message to {channelName} now?',
}, {
channelName,
});
Alert.alert(
title,
description,
buttons,
);
}
export function scheduledPostFromPost(post: Post, schedulingInfo: SchedulingInfo, postPriority?: PostPriority, postFiles?: FileInfo[]): ScheduledPost {
const fileIDs: string[] = [];
if (postFiles) {
postFiles.forEach((file) => {
if (file.id) {
fileIDs.push(file.id);
}
});
}
return {
...post,
scheduled_at: schedulingInfo.scheduled_at,
priority: post.root_id ? undefined : postPriority,
metadata: {
files: postFiles,
},
file_ids: fileIDs,
};
}
export function getPostTranslation(post: Post | PostModel, locale: string): PostTranslation | undefined {
const normalizedLocale = locale.split('-')[0];
return post.metadata?.translations?.[normalizedLocale];
}
export function getPostTranslatedMessage(originalMessage: string, translation: PostTranslation): string {
return translation.object?.message ?? originalMessage;
}
export function hasInteractivePostContent(post: Post | PostModel, mmBlocksEnabled: boolean): boolean {
if (!mmBlocksEnabled) {
return false;
}
const nAttachments = Array.isArray(post.props?.attachments) ? post.props?.attachments.length : 0;
const hasMmBlocksContent = Boolean(
(Array.isArray(post.props?.mm_blocks) && post.props.mm_blocks.length) ||
(Array.isArray(post.props?.blocks) && post.props.blocks.length) ||
(Array.isArray(post.props?.cards) && post.props.cards.length) ||
nAttachments,
);
return hasMmBlocksContent;
}
/**
* Whether the server denied this user access to the files of the post behind a permalink embed.
*
* ABAC decisions are made at render time, so the embed the server just sent is authoritative:
* it is recalculated per user on every fetch, while the stored linked post may predate the
* policy change. A non-zero count means "denied", and the server redacts all of a post's
* files or none of them.
*/
export function isPermalinkEmbedRedacted(embedData?: PermalinkEmbedData): boolean {
return getPermalinkEmbedRedactedCount(embedData) > 0;
}
function getPermalinkEmbedRedactedCount(embedData?: PermalinkEmbedData): number {
return embedData?.post?.metadata?.redacted_file_count ?? 0;
}
/**
* How many files to report as redacted for a permalink preview.
*
* The embed wins whenever it is conclusive — explicitly denied, or explicitly granted by
* listing files. Only when it says neither (no files, no redacted count, e.g. a host post
* stored before the policy was applied) do we fall back to what the linked post holds.
*/
export function getPermalinkRedactedFileCount(embedData?: PermalinkEmbedData, linkedPostRedactedCount = 0): number {
const embedRedactedCount = getPermalinkEmbedRedactedCount(embedData);
if (embedRedactedCount > 0) {
return embedRedactedCount;
}
if ((embedData?.post?.metadata?.files?.length ?? 0) > 0) {
return 0;
}
return linkedPostRedactedCount;
}
export function isPostRedactionVerified(verifiedEpoch: number | undefined, requiredEpoch: number): boolean {
return (verifiedEpoch ?? 0) >= requiredEpoch;
}
/**
* Whether a stored attachment decision may be rendered. While ABAC is enforced it must have been
* confirmed under the current required epoch. While it is not, the server redacts nothing new, but a
* denial cached while it was enforced is stale until the post is fetched again: its file rows were
* destroyed, and no since-fetch re-delivers a post whose only change is its redaction.
*/
export function isAttachmentDecisionCurrent(enforced: boolean, verifiedEpoch: number | undefined, requiredEpoch: number, redactedFileCount: number): boolean {
if (isPostRedactionVerified(verifiedEpoch, requiredEpoch)) {
return true;
}
return !enforced && redactedFileCount === 0;
}