mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-05 18:38:54 +00:00
* 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>
345 lines
13 KiB
TypeScript
345 lines
13 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;
|
||
}
|