Ai-generated post fixes (#34490)

This commit is contained in:
Ben Cooke 2025-11-19 14:00:02 -05:00 committed by GitHub
parent 188b57fbcb
commit 9ea080024b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 159 additions and 12 deletions

View file

@ -550,4 +550,108 @@ describe('PostComponent', () => {
expect(screen.queryByTestId('post-priority-label')).not.toBeInTheDocument();
});
});
describe('AI-generated indicator', () => {
const aiGeneratedPost = TestHelper.getPostMock({
channel_id: channel.id,
props: {
ai_generated_by: 'ai_user_id',
ai_generated_by_username: 'aibot',
},
});
test('should show AI-generated indicator for AI posts in non-compact mode', () => {
const props = {
...baseProps,
post: aiGeneratedPost,
compactDisplay: false,
};
renderWithContext(<PostComponent {...props}/>);
expect(screen.getByLabelText('Message posted by @aibot')).toBeInTheDocument();
});
test('should not show AI-generated indicator for regular posts', () => {
const regularPost = TestHelper.getPostMock({
channel_id: channel.id,
});
const props = {
...baseProps,
post: regularPost,
compactDisplay: false,
};
renderWithContext(<PostComponent {...props}/>);
expect(screen.queryByLabelText(/AI-generated|Message posted by/)).not.toBeInTheDocument();
});
test('should not show AI-generated indicator for consecutive posts', () => {
const props = {
...baseProps,
post: aiGeneratedPost,
compactDisplay: false,
isConsecutivePost: true,
};
renderWithContext(<PostComponent {...props}/>);
expect(screen.queryByLabelText(/AI-generated|Message posted by/)).not.toBeInTheDocument();
});
test('should show AI-generated indicator in PostUserProfile for compact mode in CENTER', () => {
const props = {
...baseProps,
post: aiGeneratedPost,
compactDisplay: true,
location: Locations.CENTER,
};
renderWithContext(<PostComponent {...props}/>);
// In compact CENTER mode, indicator is rendered by PostUserProfile (after username)
// Verify it appears exactly once
const indicators = screen.queryAllByLabelText(/AI-generated|Message posted by/);
expect(indicators.length).toBe(1);
});
test('should hide AI-generated indicator for consecutive posts in threads', () => {
const threadPost = TestHelper.getPostMock({
channel_id: channel.id,
root_id: 'root_post_id',
props: {
ai_generated_by: 'ai_user_id',
ai_generated_by_username: 'aibot',
},
});
const props = {
...baseProps,
post: threadPost,
compactDisplay: false,
isConsecutivePost: true,
location: Locations.RHS_COMMENT,
};
renderWithContext(<PostComponent {...props}/>);
expect(screen.queryByLabelText(/AI-generated|Message posted by/)).not.toBeInTheDocument();
});
test('should show AI-generated indicator for non-consecutive posts in threads', () => {
const threadPost = TestHelper.getPostMock({
channel_id: channel.id,
root_id: 'root_post_id',
props: {
ai_generated_by: 'ai_user_id',
ai_generated_by_username: 'aibot',
},
});
const props = {
...baseProps,
post: threadPost,
compactDisplay: false,
isConsecutivePost: false,
location: Locations.RHS_COMMENT,
};
renderWithContext(<PostComponent {...props}/>);
expect(screen.getByLabelText('Message posted by @aibot')).toBeInTheDocument();
});
});
});

View file

@ -589,12 +589,11 @@ function PostComponent(props: Props) {
/>
}
{priority}
{Boolean(post.props && post.props.ai_generated_by && post.props.ai_generated_by_username) &&
typeof post.props.ai_generated_by === 'string' &&
typeof post.props.ai_generated_by_username === 'string' && (
{((!props.compactDisplay && !(hasSameRoot(props) && props.isConsecutivePost)) || (props.compactDisplay && isRHS)) &&
PostUtils.hasAiGeneratedMetadata(post) && (
<AiGeneratedIndicator
userId={post.props.ai_generated_by}
username={post.props.ai_generated_by_username}
userId={post.props.ai_generated_by as string}
username={post.props.ai_generated_by_username as string}
postAuthorId={post.user_id}
/>
)}

View file

@ -9,12 +9,13 @@ import type {Post} from '@mattermost/types/posts';
import {ensureString} from 'mattermost-redux/utils/post_utils';
import AiGeneratedIndicator from 'components/post_view/ai_generated_indicator/ai_generated_indicator';
import PostHeaderCustomStatus from 'components/post_view/post_header_custom_status/post_header_custom_status';
import UserProfile from 'components/user_profile';
import BotTag from 'components/widgets/tag/bot_tag';
import Tag from 'components/widgets/tag/tag';
import {fromAutoResponder, isFromWebhook} from 'utils/post_utils';
import {fromAutoResponder, hasAiGeneratedMetadata, isFromWebhook} from 'utils/post_utils';
type Props = {
post: Post;
@ -25,20 +26,33 @@ type Props = {
isBot: boolean;
isSystemMessage: boolean;
isMobileView: boolean;
location: string;
};
const PostUserProfile = (props: Props): JSX.Element | null => {
const intl = useIntl();
const {post, compactDisplay, isMobileView, isConsecutivePost, enablePostUsernameOverride, isBot, isSystemMessage, colorizeUsernames} = props;
const {post, compactDisplay, isMobileView, isConsecutivePost, enablePostUsernameOverride, isBot, isSystemMessage, colorizeUsernames, location} = props;
const isFromAutoResponder = fromAutoResponder(post);
const colorize = compactDisplay && colorizeUsernames;
let userProfile: ReactNode = null;
let botIndicator = null;
let colon = null;
let aiIndicator = null;
if (props.compactDisplay) {
colon = <strong className='colon'>{':'}</strong>;
// Add AI indicator in compact mode after username, but not in RHS thread view (it goes after timestamp there)
if (hasAiGeneratedMetadata(post) && !(location === 'RHS_ROOT' || location === 'RHS_COMMENT')) {
aiIndicator = (
<AiGeneratedIndicator
userId={post.props.ai_generated_by as string}
username={post.props.ai_generated_by_username as string}
postAuthorId={post.user_id}
/>
);
}
}
const customStatus = (
@ -148,6 +162,7 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
{colon}
{botIndicator}
{customStatus}
{aiIndicator}
</div>);
};

View file

@ -13,11 +13,14 @@ import {ensureString} from 'mattermost-redux/utils/post_utils';
import FileAttachmentListContainer from 'components/file_attachment_list';
import PriorityLabel from 'components/post_priority/post_priority_label';
import AiGeneratedIndicator from 'components/post_view/ai_generated_indicator/ai_generated_indicator';
import PostAttachmentOpenGraph from 'components/post_view/post_attachment_opengraph';
import PostMessageView from 'components/post_view/post_message_view';
import Timestamp from 'components/timestamp';
import UserProfileComponent from 'components/user_profile';
import * as PostUtils from 'utils/post_utils';
import PreviewPostAvatar from './avatar/avatar';
import PostAttachmentContainer from '../post_attachment_container/post_attachment_container';
@ -161,6 +164,13 @@ const PostMessagePreview = (props: Props) => {
<PriorityLabel priority={previewPost.metadata.priority.priority}/>
</span>
)}
{PostUtils.hasAiGeneratedMetadata(previewPost) && (
<AiGeneratedIndicator
userId={previewPost.props.ai_generated_by as string}
username={previewPost.props.ai_generated_by_username as string}
postAuthorId={previewPost.user_id}
/>
)}
</div>
</div>
<PostMessageView

View file

@ -1613,7 +1613,7 @@
.ai-generated-indicator {
display: inline-flex;
align-items: center;
margin-left: 8px;
margin-left: 4px;
color: inherit;
opacity: 0.73;
@ -1623,6 +1623,10 @@
}
}
&.post--compact .col__name .ai-generated-indicator {
margin-left: 0px;
}
.post__permalink {
display: inline-block;
color: inherit;

View file

@ -635,10 +635,25 @@ export function areConsecutivePostsBySameUser(post: Post, previousPost: Post): b
if (!(post && previousPost)) {
return false;
}
return post.user_id === previousPost.user_id && // The post is by the same user
post.create_at - previousPost.create_at <= Posts.POST_COLLAPSE_TIMEOUT && // And was within a short time period
!(post.props && post.props.from_webhook) && !(previousPost.props && previousPost.props.from_webhook) && // And neither is from a webhook
!isSystemMessage(post) && !isSystemMessage(previousPost); // And neither is a system message
const sameUser = post.user_id === previousPost.user_id;
const withinTimeWindow = post.create_at - previousPost.create_at <= Posts.POST_COLLAPSE_TIMEOUT;
const notFromWebhook = !(post.props && post.props.from_webhook) && !(previousPost.props && previousPost.props.from_webhook);
const notSystemMessage = !isSystemMessage(post) && !isSystemMessage(previousPost);
const sameAiGeneratedStatus = post.props?.ai_generated_by === previousPost.props?.ai_generated_by;
return sameUser &&
withinTimeWindow &&
notFromWebhook &&
notSystemMessage &&
sameAiGeneratedStatus;
}
// Checks if a post has valid AI-generated metadata
export function hasAiGeneratedMetadata(post: Post): boolean {
return Boolean(post.props && post.props.ai_generated_by && post.props.ai_generated_by_username) &&
typeof post.props.ai_generated_by === 'string' &&
typeof post.props.ai_generated_by_username === 'string';
}
// Constructs the URL of a post.