mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-06 02:40:15 +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>
398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
// 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;
|
||
}
|