mattermost-mobile-upstream/app/utils/post/index.ts
Nick Misasi f1e263ba90
[MM-66230] Add AI-generated sparkle indicator to post headers (#9835)
* Add AI-generated sparkle indicator to post headers for mobile/web parity.

Users on mobile could not tell when a post was AI-generated; this surfaces
the same sparkle marker as web using post props and keeps headers visible
for consecutive AI posts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Make AI-generated indicator accessible to screen readers.

CodeRabbit flagged that accessibilityLabel alone is not announced on a
standard View; set accessible and accessibilityRole so VoiceOver/TalkBack
can surface the AI attribution label.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: retrigger docs-impact-review workflow.

Prior runs failed due to Claude Code SDK initialization errors unrelated
to PR changes; retry to clear the flaky check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address review nits for AI-generated post indicator.

Compute isAiGenerated once and only check previousPost in areConsecutivePosts.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
2026-07-14 19:31:02 +00:00

345 lines
13 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;
}