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

786 lines
27 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 {createIntl} from 'react-intl';
import {Alert} from 'react-native';
import {getUsersCountFromMentions} from '@actions/local/post';
import {General, Post} from '@constants';
import {mockedPosts} from '@database/operator/utils/mock';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import {getUserById} from '@queries/servers/user';
import TestHelper from '@test/test_helper';
import {toMilliseconds} from '@utils/datetime';
import {
areConsecutivePosts,
hasAiGeneratedMetadata,
isFromWebhook,
isEdited,
isPostEphemeral,
restoreEphemeralIdentityFieldsForEdit,
isPostFailed,
isPostPendingOrFailed,
isSystemMessage,
fromAutoResponder,
postUserDisplayName,
shouldIgnorePost,
processPostsFetched,
getLastFetchedAtFromPosts,
moreThan5minAgo,
hasSpecialMentions,
persistentNotificationsConfirmation,
scheduledPostFromPost,
hasInteractivePostContent,
} from './index';
jest.mock('@actions/local/post', () => ({
getUsersCountFromMentions: jest.fn(),
}));
jest.mock('@queries/servers/user', () => ({
getUserById: jest.fn(),
}));
jest.mock('@database/manager', () => ({
getServerDatabaseAndOperator: jest.fn().mockReturnValue({
database: {},
}),
}));
describe('post utils', () => {
describe('hasAiGeneratedMetadata', () => {
it('should return true when both ai_generated_by and ai_generated_by_username are non-empty strings', () => {
const post = TestHelper.fakePostModel({
props: {
ai_generated_by: 'bot-user-id',
ai_generated_by_username: 'ai-bot',
},
});
expect(hasAiGeneratedMetadata(post)).toBe(true);
});
it('should return false when metadata is missing or invalid', () => {
expect(hasAiGeneratedMetadata(TestHelper.fakePostModel({
props: {ai_generated_by_username: 'ai-bot'},
}))).toBe(false);
expect(hasAiGeneratedMetadata(TestHelper.fakePostModel({
props: {ai_generated_by: 'bot-user-id'},
}))).toBe(false);
expect(hasAiGeneratedMetadata(TestHelper.fakePostModel({
props: {ai_generated_by: '', ai_generated_by_username: ''},
}))).toBe(false);
expect(hasAiGeneratedMetadata(TestHelper.fakePostModel({
props: {ai_generated_by: 123, ai_generated_by_username: true},
}))).toBe(false);
});
});
describe('areConsecutivePosts', () => {
it('should return true for consecutive posts from the same user within the collapse timeout', () => {
const post = TestHelper.fakePostModel({
userId: 'user1',
createAt: 1000,
props: {},
});
const previousPost = TestHelper.fakePostModel({
userId: 'user1',
createAt: 500,
props: {},
});
const result = areConsecutivePosts(post, previousPost, 'en');
expect(result).toBe(true);
});
it('should return false for posts from different users', () => {
const post = TestHelper.fakePostModel({
userId: 'user1',
createAt: 1000,
props: {},
});
const previousPost = TestHelper.fakePostModel({
userId: 'user2',
createAt: 500,
props: {},
});
const result = areConsecutivePosts(post, previousPost, 'en');
expect(result).toBe(false);
});
it('should return false for normal post after AI post from same user', () => {
const post = TestHelper.fakePostModel({
userId: 'user1',
createAt: 1000,
props: {},
});
const previousPost = TestHelper.fakePostModel({
userId: 'user1',
createAt: 500,
props: {
ai_generated_by: 'user1',
ai_generated_by_username: 'ai-bot',
},
});
const result = areConsecutivePosts(post, previousPost, 'en');
expect(result).toBe(false);
});
});
describe('isFromWebhook', () => {
it('should return true for posts from a webhook', () => {
const post = TestHelper.fakePostModel({
props: {
from_webhook: 'true',
},
});
const result = isFromWebhook(post);
expect(result).toBe(true);
});
it('should return false for posts not from a webhook', () => {
const post = TestHelper.fakePostModel({
props: {
from_webhook: 'false',
},
});
const result = isFromWebhook(post);
expect(result).toBe(false);
});
});
describe('isEdited', () => {
it('should return true if the post is edited', () => {
const post = TestHelper.fakePostModel({
editAt: 1000,
});
const result = isEdited(post);
expect(result).toBe(true);
});
it('should return false if the post is not edited', () => {
const post = TestHelper.fakePostModel({
editAt: 0,
});
const result = isEdited(post);
expect(result).toBe(false);
});
});
describe('isPostEphemeral', () => {
it('should return true for an ephemeral post', () => {
const post = TestHelper.fakePostModel({
type: Post.POST_TYPES.EPHEMERAL,
});
const result = isPostEphemeral(post);
expect(result).toBe(true);
});
it('should return false for a non-ephemeral post', () => {
const post = TestHelper.fakePostModel({
type: '',
});
const result = isPostEphemeral(post);
expect(result).toBe(false);
});
});
describe('restoreEphemeralIdentityFieldsForEdit', () => {
it('should preserve create_at and root_id when an edited ephemeral arrives with zero create_at', () => {
const incoming = TestHelper.fakePost({
id: 'eph1',
type: Post.POST_TYPES.EPHEMERAL,
message: 'after',
create_at: 0,
update_at: 9000,
root_id: '',
user_id: 'user1',
});
const result = restoreEphemeralIdentityFieldsForEdit(incoming, {
create_at: 5000,
root_id: 'root1',
user_id: 'user1',
});
expect(result.create_at).toBe(5000);
expect(result.root_id).toBe('root1');
expect(result.message).toBe('after');
expect(result.update_at).toBe(9000);
});
it('should preserve user_id when an edited ephemeral arrives with empty user_id', () => {
const incoming = TestHelper.fakePost({
id: 'eph1',
type: Post.POST_TYPES.EPHEMERAL,
message: 'after',
create_at: 5000,
update_at: 9000,
user_id: '',
});
const result = restoreEphemeralIdentityFieldsForEdit(incoming, {
create_at: 5000,
user_id: 'creator1',
});
expect(result.user_id).toBe('creator1');
expect(result.message).toBe('after');
});
});
describe('isPostFailed', () => {
it('should return true if the post has failed prop', () => {
const post = TestHelper.fakePostModel({
props: {
failed: true,
},
pendingPostId: 'id',
id: 'id',
updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000,
});
const result = isPostFailed(post);
expect(result).toBe(true);
});
it('should return true if the post is pending and the update time has exceeded the failure time', () => {
const post = TestHelper.fakePostModel({
props: {},
pendingPostId: 'id',
id: 'id',
updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000,
});
const result = isPostFailed(post);
expect(result).toBe(true);
});
it('should return false if the post is not failed', () => {
const post = TestHelper.fakePostModel({
props: {},
pendingPostId: 'id',
id: 'id',
updateAt: Date.now(),
});
const result = isPostFailed(post);
expect(result).toBe(false);
});
});
describe('isPostPendingOrFailed', () => {
it('should return true if the post is pending', () => {
const post = TestHelper.fakePostModel({
pendingPostId: 'id',
id: 'id',
props: {},
});
const result = isPostPendingOrFailed(post);
expect(result).toBe(true);
});
it('should return true if the post has failed', () => {
const post = TestHelper.fakePostModel({
pendingPostId: 'id',
id: 'id',
updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000,
props: {},
});
const result = isPostPendingOrFailed(post);
expect(result).toBe(true);
});
it('should return false if the post is neither pending nor failed', () => {
const post = TestHelper.fakePostModel({
pendingPostId: 'differentId',
id: 'id',
props: {},
});
const result = isPostPendingOrFailed(post);
expect(result).toBe(false);
});
});
describe('isSystemMessage', () => {
it('should return true if the post is a system message', () => {
const post = TestHelper.fakePostModel({
type: `${Post.POST_TYPES.SYSTEM_MESSAGE_PREFIX}any_type` as PostType,
});
const result = isSystemMessage(post);
expect(result).toBe(true);
});
it('should return false if the post is not a system message', () => {
const post = TestHelper.fakePostModel({
type: 'add_bot_teams_channels',
});
const result = isSystemMessage(post);
expect(result).toBe(false);
});
});
describe('hasSpecialMentions', () => {
test.each([
['@here where is Jessica Hyde', true],
['@all where is Jessica Hyde', true],
['@channel where is Jessica Hyde', true],
['where is Jessica Hyde @here', true],
['where is Jessica Hyde @all', true],
['where is Jessica Hyde @channel', true],
['where is Jessica @here Hyde', true],
['where is Jessica @all Hyde', true],
['where is Jessica @channel Hyde', true],
['where is Jessica Hyde\n@here', true],
['where is Jessica Hyde\n@all', true],
['where is Jessica Hyde\n@channel', true],
['where is Jessica\n@here Hyde', true],
['where is Jessica\n@all Hyde', true],
['where is Jessica\n@channel Hyde', true],
['where is Jessica Hyde @her', false],
['where is Jessica Hyde @al', false],
['where is Jessica Hyde @chann', false],
['where is Jessica Hyde@here', false],
['where is Jessica Hyde@all', false],
['where is Jessica Hyde@channel', false],
['where is Jessica @hereHyde', false],
['where is Jessica @allHyde', false],
['where is Jessica @channelHyde', false],
['@herewhere is Jessica Hyde@here', false],
['@allwhere is Jessica Hyde@all', false],
['@channelwhere is Jessica Hyde@channel', false],
['where is Jessica Hyde here', false],
['where is Jessica Hyde all', false],
['where is Jessica Hyde channel', false],
['where is Jessica Hyde', false],
])('hasSpecialMentions: %s => %s', (message, expected) => {
expect(hasSpecialMentions(message)).toBe(expected);
});
});
describe('fromAutoResponder', () => {
it('should return true if the post is from an auto responder', () => {
const post = TestHelper.fakePostModel({
type: Post.POST_TYPES.SYSTEM_AUTO_RESPONDER,
});
const result = fromAutoResponder(post);
expect(result).toBe(true);
});
it('should return false if the post is not from an auto responder', () => {
const post = TestHelper.fakePostModel({
type: 'add_bot_teams_channels',
});
const result = fromAutoResponder(post);
expect(result).toBe(false);
});
});
describe('persistentNotificationsConfirmation', () => {
const serverUrl = 'http://server';
const value = '@user';
const mentionsList = ['@user'];
const sendMessage = jest.fn();
const persistentNotificationMaxRecipients = 10;
const persistentNotificationInterval = 5;
const currentUserId = 'current_user_id';
const channelName = 'channel_id__teammate_id';
const intl = createIntl({locale: DEFAULT_LOCALE, messages: getTranslations(DEFAULT_LOCALE)});
it('should show alert with DM channel description when channelType is DM_CHANNEL', async () => {
const mockUser = TestHelper.fakeUserModel({username: 'teammate'});
jest.mocked(getUserById).mockResolvedValue(mockUser);
await persistentNotificationsConfirmation(
serverUrl,
value,
mentionsList,
intl,
sendMessage,
persistentNotificationMaxRecipients,
persistentNotificationInterval,
currentUserId,
channelName,
General.DM_CHANNEL,
);
expect(Alert.alert).toHaveBeenCalledWith(
'Send persistent notifications',
`${mockUser.username} will be notified every ${persistentNotificationInterval} minutes until they’ve acknowledged or replied to the message.`,
expect.any(Array),
);
});
it('should show alert when special mentions are present', async () => {
await persistentNotificationsConfirmation(
serverUrl,
'@channel',
mentionsList,
intl,
sendMessage,
persistentNotificationMaxRecipients,
persistentNotificationInterval,
currentUserId,
channelName,
);
expect(Alert.alert).toHaveBeenCalledWith(
'',
'Cannot use @channel, @all or @here to mention recipients of persistent notifications.',
expect.any(Array),
);
});
it('should show alert when no mentions found', async () => {
jest.mocked(getUsersCountFromMentions).mockResolvedValue(0);
await persistentNotificationsConfirmation(
serverUrl,
value,
mentionsList,
intl,
sendMessage,
persistentNotificationMaxRecipients,
persistentNotificationInterval,
currentUserId,
channelName,
);
expect(Alert.alert).toHaveBeenCalledWith(
'Recipients must be @mentioned',
'There are no recipients mentioned in your message. You’ll need add mentions to be able to send persistent notifications.',
expect.any(Array),
);
});
it('should show alert when mentions exceed max recipients', async () => {
jest.mocked(getUsersCountFromMentions).mockResolvedValue(15);
await persistentNotificationsConfirmation(
serverUrl,
value,
mentionsList,
intl,
sendMessage,
persistentNotificationMaxRecipients,
persistentNotificationInterval,
currentUserId,
channelName,
);
expect(Alert.alert).toHaveBeenCalledWith(
'Too many recipients',
`You can send persistent notifications to a maximum of ${persistentNotificationMaxRecipients} recipients. There are ${mentionsList.length} recipients mentioned in your message. You’ll need to change who you’ve mentioned before you can send.`,
expect.any(Array),
);
});
it('should show confirmation alert for valid mentions within limit', async () => {
jest.mocked(getUsersCountFromMentions).mockResolvedValue(5);
await persistentNotificationsConfirmation(
serverUrl,
value,
mentionsList,
intl,
sendMessage,
persistentNotificationMaxRecipients,
persistentNotificationInterval,
currentUserId,
channelName,
);
expect(Alert.alert).toHaveBeenCalledWith(
'Send persistent notifications',
`Mentioned recipients will be notified every ${persistentNotificationInterval} minutes until they’ve acknowledged or replied to the message.`,
expect.any(Array),
);
});
});
describe('postUserDisplayName', () => {
it('should return the override username if from webhook and override is enabled', () => {
const post = TestHelper.fakePostModel({
props: {
from_webhook: 'true',
override_username: 'webhook_user',
},
});
const result = postUserDisplayName(post, undefined, undefined, true);
expect(result).toBe('webhook_user');
});
it('should return the author’s display name if not from webhook or override is disabled', () => {
const post = TestHelper.fakePostModel({
props: {
from_webhook: 'false',
},
});
const author = TestHelper.fakeUserModel({
username: 'user1',
locale: 'en',
});
const result = postUserDisplayName(post, author, undefined, false);
expect(result).toBe('user1');
});
it('should return the author’s display name using the teammate name display', () => {
const post = TestHelper.fakePostModel({
props: {
from_webhook: 'false',
},
});
const author = TestHelper.fakeUserModel({
username: 'user1',
locale: 'en',
});
const result = postUserDisplayName(post, author, 'nickname', false);
expect(result).toBe('user1');
});
});
describe('shouldIgnorePost', () => {
it('should return true if the post type is in the ignore list', () => {
const post = TestHelper.fakePost({
type: Post.POST_TYPES.CHANNEL_DELETED,
});
const result = shouldIgnorePost(post);
expect(result).toBe(true);
});
it('should return false if the post type is not in the ignore list', () => {
const post = TestHelper.fakePost({
type: Post.POST_TYPES.EPHEMERAL,
});
const result = shouldIgnorePost(post);
expect(result).toBe(false);
});
});
describe('processPostsFetched', () => {
it('should process the fetched posts correctly', () => {
const data = {
order: ['post1', 'post2'],
posts: {
post1: TestHelper.fakePost({id: 'post1', message: 'First post'}),
post2: TestHelper.fakePost({id: 'post2', message: 'Second post'}),
},
prev_post_id: 'post0',
};
const result = processPostsFetched(data);
expect(result).toEqual({
posts: [
expect.objectContaining({id: 'post1', message: 'First post'}),
expect.objectContaining({id: 'post2', message: 'Second post'}),
],
order: ['post1', 'post2'],
previousPostId: 'post0',
});
});
});
describe('getLastFetchedAtFromPosts', () => {
it('should return the maximum timestamp from the posts', () => {
const posts = [
TestHelper.fakePost({create_at: 1000, update_at: 2000, delete_at: 0}),
TestHelper.fakePost({create_at: 1500, update_at: 2500, delete_at: 3000}),
];
const result = getLastFetchedAtFromPosts(posts);
expect(result).toBe(3000);
});
it('should return 0 if no posts are provided', () => {
const result = getLastFetchedAtFromPosts();
expect(result).toBe(0);
});
});
describe('moreThan5minAgo', () => {
it('should return true if the time is more than 5 minutes ago', () => {
const time = Date.now() - toMilliseconds({minutes: 6});
const result = moreThan5minAgo(time);
expect(result).toBe(true);
});
it('should return false if the time is within 5 minutes', () => {
const time = Date.now() - toMilliseconds({minutes: 4});
const result = moreThan5minAgo(time);
expect(result).toBe(false);
});
});
describe('hasInteractivePostContent', () => {
it('should return false when mmBlocksEnabled is false', () => {
const post = TestHelper.fakePostModel({
props: {
mm_blocks: [{type: 'text', text: {type: 'plain_text', text: 'Hello'}}],
},
});
expect(hasInteractivePostContent(post, false)).toBe(false);
});
it('should return false when mmBlocksEnabled is true but props are missing', () => {
const post = TestHelper.fakePostModel({props: undefined});
expect(hasInteractivePostContent(post, true)).toBe(false);
});
it('should return false when all interactive arrays are empty', () => {
const post = TestHelper.fakePostModel({
props: {
mm_blocks: [],
blocks: [],
cards: [],
attachments: [],
},
});
expect(hasInteractivePostContent(post, true)).toBe(false);
});
it('should return false when interactive props are not arrays', () => {
const post = TestHelper.fakePostModel({
props: {
mm_blocks: 'invalid',
blocks: {},
cards: null,
attachments: 'invalid',
},
});
expect(hasInteractivePostContent(post, true)).toBe(false);
});
it('should return true when mm_blocks are present', () => {
const post = TestHelper.fakePostModel({
props: {
mm_blocks: [{type: 'text', text: {type: 'plain_text', text: 'Hello'}}],
},
});
expect(hasInteractivePostContent(post, true)).toBe(true);
});
it('should return true when blocks are present', () => {
const post = TestHelper.fakePostModel({
props: {
blocks: [{type: 'section', text: {type: 'mrkdwn', text: 'Block content'}}],
},
});
expect(hasInteractivePostContent(post, true)).toBe(true);
});
it('should return true when cards are present', () => {
const post = TestHelper.fakePostModel({
props: {
cards: [{header: {title: 'Card title'}, sections: []}],
},
});
expect(hasInteractivePostContent(post, true)).toBe(true);
});
it('should return true when attachments are present', () => {
const post = TestHelper.fakePostModel({
props: {
attachments: [{text: 'Attachment body'}],
},
});
expect(hasInteractivePostContent(post, true)).toBe(true);
});
it('should work with Post objects', () => {
const post = TestHelper.fakePost({
props: {
attachments: [{text: 'Attachment body'}],
},
});
expect(hasInteractivePostContent(post, true)).toBe(true);
});
});
describe('scheduledPostFromPost', () => {
const post: Post = mockedPosts.posts[mockedPosts.order[0]];
const schedulingInfo: SchedulingInfo = {
scheduled_at: Date.now() + 10000,
};
const postPriority: PostPriority = {
priority: 'important',
};
const postFiles: FileInfo[] = [
TestHelper.fakeFileInfo({id: 'fileid1'}),
TestHelper.fakeFileInfo({id: 'fileid2'}),
];
it('should create a scheduled post with the given scheduling info', () => {
const result: ScheduledPost = scheduledPostFromPost(post, schedulingInfo);
expect(result.scheduled_at).toBe(schedulingInfo.scheduled_at);
});
it('should not include the post priority if its a post in thread', () => {
const result: ScheduledPost = scheduledPostFromPost(post, schedulingInfo, postPriority);
expect(result.priority).toBeUndefined();
});
it('should include the file IDs if post files are provided', () => {
const result: ScheduledPost = scheduledPostFromPost(post, schedulingInfo, postPriority, postFiles);
expect(result.file_ids).toEqual(['fileid1', 'fileid2']);
});
it('should include the post files if provided', () => {
const result: ScheduledPost = scheduledPostFromPost(post, schedulingInfo, postPriority, postFiles);
expect(result.metadata?.files).toEqual(postFiles);
});
it('should return a scheduled post with the same properties as the original post', () => {
const result: ScheduledPost = scheduledPostFromPost(post, schedulingInfo);
expect(result.id).toBe(post.id);
expect(result.message).toBe(post.message);
expect(result.channel_id).toBe(post.channel_id);
expect(result.create_at).toBe(post.create_at);
expect(result.update_at).toBe(post.update_at);
expect(result.user_id).toBe(post.user_id);
});
});
});