From eb031f4c3c51f6566724324527f85e5b863ce1cf Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:19:42 +0200 Subject: [PATCH] [web] parse mentions in comments (#34055) GitOrigin-RevId: 185485a3ac9670c46d0e7c43bf281a5ffee3f784 --- .../Features/Project/ProjectController.mjs | 1 + .../review-panel/components/mention-badge.tsx | 28 ++++ .../review-panel-expandable-content.tsx | 36 ++++- .../components/review-panel-message.tsx | 5 + .../review-panel/utils/parse-mentions.ts | 61 ++++++++ .../pages/editor/review-panel.scss | 4 + .../components/mention-badge.test.tsx | 78 ++++++++++ .../review-panel/utils/parse-mentions.test.ts | 137 ++++++++++++++++++ 8 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 services/web/frontend/js/features/review-panel/components/mention-badge.tsx create mode 100644 services/web/frontend/js/features/review-panel/utils/parse-mentions.ts create mode 100644 services/web/test/frontend/features/review-panel/components/mention-badge.test.tsx create mode 100644 services/web/test/frontend/features/review-panel/utils/parse-mentions.test.ts diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index d9fa175b51..27cee1f44e 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -463,6 +463,7 @@ const _ProjectController = { 'word-count-client', 'editor-popup-ux-survey-03-2026', 'chat-edit-delete', + 'comment-mentions', 'ai-workbench-release', 'compile-timeout-target-plans', 'writefull-figure-generator', diff --git a/services/web/frontend/js/features/review-panel/components/mention-badge.tsx b/services/web/frontend/js/features/review-panel/components/mention-badge.tsx new file mode 100644 index 0000000000..ba92c47bd6 --- /dev/null +++ b/services/web/frontend/js/features/review-panel/components/mention-badge.tsx @@ -0,0 +1,28 @@ +import { useId, type FC } from 'react' +import { useTranslation } from 'react-i18next' +import { UserId } from '../../../../../types/user' +import { useChangesUsersContext } from '../context/changes-users-context' +import { buildName } from '../utils/build-name' +import OLTooltip from '@/shared/components/ol/ol-tooltip' + +export const MentionBadge: FC<{ userId: UserId }> = ({ userId }) => { + const { t } = useTranslation() + const id = useId() + const changesUsers = useChangesUsersContext() + const user = changesUsers?.get(userId) + + if (!user) { + return @{t('unknown')} + } + + return ( + + @{buildName(user)} + + ) +} diff --git a/services/web/frontend/js/features/review-panel/components/review-panel-expandable-content.tsx b/services/web/frontend/js/features/review-panel/components/review-panel-expandable-content.tsx index 56651991b1..8cfe1b776d 100644 --- a/services/web/frontend/js/features/review-panel/components/review-panel-expandable-content.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-panel-expandable-content.tsx @@ -1,8 +1,14 @@ -import { memo, useCallback, useRef, useState } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import OLButton from '@/shared/components/ol/ol-button' import { useTranslation } from 'react-i18next' import classNames from 'classnames' import { PreventSelectingEntry } from './review-panel-prevent-selecting' +import { MentionBadge } from './mention-badge' +import { + MentionSegment, + parseMentions, + sliceMentionSegments, +} from '../utils/parse-mentions' export const ExpandableContent = memo<{ className?: string @@ -12,6 +18,7 @@ export const ExpandableContent = memo<{ checkNewLines?: boolean inline?: boolean translate?: 'yes' | 'no' + displayMentions?: boolean }>(function ExpandableContent({ content, className, @@ -20,6 +27,7 @@ export const ExpandableContent = memo<{ checkNewLines = true, inline = false, translate, + displayMentions = false, }) { const { t } = useTranslation() const contentRef = useRef(null) @@ -33,6 +41,19 @@ export const ExpandableContent = memo<{ const isOverflowing = content.length > limit + const segments = useMemo( + () => (displayMentions ? parseMentions(content) : null), + [content, displayMentions] + ) + + const renderedContent = segments + ? renderSegments( + isExpanded ? segments : sliceMentionSegments(segments, limit) + ) + : isExpanded + ? content + : content.slice(0, limit) + const handleShowMore = useCallback(() => { setIsExpanded(true) contentRef.current?.dispatchEvent( @@ -54,7 +75,7 @@ export const ExpandableContent = memo<{ className={classNames('review-panel-expandable-content', className)} translate={translate} > - {isExpanded ? content : content.slice(0, limit)} + {renderedContent} {isOverflowing && !isExpanded && '...'}
{ + if (segment.type === 'mention') { + return ( + + ) + } + return segment.value + }) +} + function indexOfNthLine(content: string, n: number) { if (n < 1) return null diff --git a/services/web/frontend/js/features/review-panel/components/review-panel-message.tsx b/services/web/frontend/js/features/review-panel/components/review-panel-message.tsx index 93e93391bd..93a0ef0a72 100644 --- a/services/web/frontend/js/features/review-panel/components/review-panel-message.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-panel-message.tsx @@ -15,6 +15,7 @@ import { useUserContext } from '@/shared/context/user-context' import ReviewPanelEntryUser from './review-panel-entry-user' import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' import { PreventSelectingEntry } from './review-panel-prevent-selecting' +import { isSplitTestEnabled } from '@/utils/splitTestUtils' export const ReviewPanelMessage: FC<{ message: ReviewPanelCommentThreadMessage @@ -41,6 +42,9 @@ export const ReviewPanelMessage: FC<{ const [content, setContent] = useState(message.content) const user = useUserContext() const permissions = usePermissionsContext() + const mentionsEnabled = + isSplitTestEnabled('email-notifications') && + isSplitTestEnabled('comment-mentions') const isCommentAuthor = Boolean(message.user && user.id === message.user.id) const canEdit = isCommentAuthor && permissions.comment @@ -141,6 +145,7 @@ export const ReviewPanelMessage: FC<{ checkNewLines content={message.content} translate="no" + displayMentions={mentionsEnabled} /> )} diff --git a/services/web/frontend/js/features/review-panel/utils/parse-mentions.ts b/services/web/frontend/js/features/review-panel/utils/parse-mentions.ts new file mode 100644 index 0000000000..d89fa3765b --- /dev/null +++ b/services/web/frontend/js/features/review-panel/utils/parse-mentions.ts @@ -0,0 +1,61 @@ +import { UserId } from '../../../../../types/user' + +export type MentionSegment = + | { type: 'text'; value: string } + | { type: 'mention'; userId: UserId } + +const MENTION_REGEX = /@\[([a-f0-9]{24})\]/g + +export function parseMentions(content: string): MentionSegment[] { + const segments: MentionSegment[] = [] + let lastIndex = 0 + + for (const match of content.matchAll(MENTION_REGEX)) { + const matchStart = match.index + if (matchStart > lastIndex) { + segments.push({ + type: 'text', + value: content.slice(lastIndex, matchStart), + }) + } + segments.push({ type: 'mention', userId: match[1] as UserId }) + lastIndex = matchStart + match[0].length + } + + if (lastIndex < content.length) { + segments.push({ type: 'text', value: content.slice(lastIndex) }) + } + + return segments +} + +const MENTION_RAW_LENGTH = 27 // @[ + 24 hex chars + ] + +export function sliceMentionSegments( + segments: MentionSegment[], + limit: number +): MentionSegment[] { + const result: MentionSegment[] = [] + let consumed = 0 + + for (const segment of segments) { + if (consumed >= limit) break + + const length = + segment.type === 'text' ? segment.value.length : MENTION_RAW_LENGTH + const remaining = limit - consumed + + if (length <= remaining) { + result.push(segment) + consumed += length + continue + } + + if (segment.type === 'text') { + result.push({ type: 'text', value: segment.value.slice(0, remaining) }) + } + break + } + + return result +} diff --git a/services/web/frontend/stylesheets/pages/editor/review-panel.scss b/services/web/frontend/stylesheets/pages/editor/review-panel.scss index 7fa11fec01..ba52f04b0a 100644 --- a/services/web/frontend/stylesheets/pages/editor/review-panel.scss +++ b/services/web/frontend/stylesheets/pages/editor/review-panel.scss @@ -418,6 +418,10 @@ del.review-panel-content-highlight { } } +.review-panel-mention { + color: var(--content-info-themed); +} + .review-panel-comment-input { width: 100%; font-size: var(--rp-base-font-size); diff --git a/services/web/test/frontend/features/review-panel/components/mention-badge.test.tsx b/services/web/test/frontend/features/review-panel/components/mention-badge.test.tsx new file mode 100644 index 0000000000..2e7a9791f1 --- /dev/null +++ b/services/web/test/frontend/features/review-panel/components/mention-badge.test.tsx @@ -0,0 +1,78 @@ +import { expect } from 'chai' +import { render, screen } from '@testing-library/react' +import { MentionBadge } from '@/features/review-panel/components/mention-badge' +import { + ChangesUsersContext, + ChangesUsers, +} from '@/features/review-panel/context/changes-users-context' +import { UserId } from '@ol-types/user' + +const userId = 'aabbccddeeff00112233aabb' as UserId + +function renderMentionBadge(changesUsers?: ChangesUsers) { + return render( + + + + ) +} + +describe('', function () { + it('renders @Unknown when user is not in context', function () { + renderMentionBadge(new Map()) + expect(screen.getByText('@Unknown')).to.exist + }) + + it('renders @Unknown when context is undefined', function () { + renderMentionBadge(undefined) + expect(screen.getByText('@Unknown')).to.exist + }) + + it('renders the display name when user is found', function () { + const users: ChangesUsers = new Map([ + [ + userId, + { + id: userId, + email: 'jane@example.com', + first_name: 'Jane', + last_name: 'Doe', + }, + ], + ]) + renderMentionBadge(users) + expect(screen.getByText('@Jane Doe')).to.exist + }) + + it('falls back to email prefix when user has no name', function () { + const users: ChangesUsers = new Map([ + [ + userId, + { + id: userId, + email: 'jane@example.com', + }, + ], + ]) + renderMentionBadge(users) + expect(screen.getByText('@jane')).to.exist + }) + + it('renders as a text mention when user is found', function () { + const users: ChangesUsers = new Map([ + [ + userId, + { + id: userId, + email: 'jane@example.com', + first_name: 'Jane', + last_name: 'Doe', + }, + ], + ]) + renderMentionBadge(users) + const badge = screen.getByText('@Jane Doe') + expect(badge.tagName).to.equal('SPAN') + expect(badge.classList.contains('review-panel-mention')).to.be.true + }) +}) diff --git a/services/web/test/frontend/features/review-panel/utils/parse-mentions.test.ts b/services/web/test/frontend/features/review-panel/utils/parse-mentions.test.ts new file mode 100644 index 0000000000..b9f14c1016 --- /dev/null +++ b/services/web/test/frontend/features/review-panel/utils/parse-mentions.test.ts @@ -0,0 +1,137 @@ +import { expect } from 'chai' +import { + parseMentions, + sliceMentionSegments, +} from '@/features/review-panel/utils/parse-mentions' +import { UserId } from '@ol-types/user' + +describe('parseMentions', function () { + it('should return a single text segment for plain text', function () { + expect(parseMentions('hello world')).to.deep.equal([ + { type: 'text', value: 'hello world' }, + ]) + }) + + it('should return an empty array for an empty string', function () { + expect(parseMentions('')).to.deep.equal([]) + }) + + it('should parse a single mention', function () { + const userId = 'aabbccddeeff00112233aabb' as UserId + expect(parseMentions(`@[${userId}]`)).to.deep.equal([ + { type: 'mention', userId }, + ]) + }) + + it('should parse text before and after a mention', function () { + const userId = 'aabbccddeeff00112233aabb' as UserId + expect(parseMentions(`hey @[${userId}] check this`)).to.deep.equal([ + { type: 'text', value: 'hey ' }, + { type: 'mention', userId }, + { type: 'text', value: ' check this' }, + ]) + }) + + it('should parse multiple mentions', function () { + const userId1 = 'aabbccddeeff00112233aabb' as UserId + const userId2 = '112233445566778899aabbcc' as UserId + expect(parseMentions(`@[${userId1}] and @[${userId2}]`)).to.deep.equal([ + { type: 'mention', userId: userId1 }, + { type: 'text', value: ' and ' }, + { type: 'mention', userId: userId2 }, + ]) + }) + + it('should not match bare @userId without brackets', function () { + expect(parseMentions('@aabbccddeeff00112233aabb')).to.deep.equal([ + { type: 'text', value: '@aabbccddeeff00112233aabb' }, + ]) + }) + + it('should not match @[...] with non-hex characters', function () { + expect(parseMentions('@[gghhiijjkkllmmnnooppqqrr]')).to.deep.equal([ + { type: 'text', value: '@[gghhiijjkkllmmnnooppqqrr]' }, + ]) + }) + + it('should not match @[...] with wrong length', function () { + expect(parseMentions('@[aabbcc]')).to.deep.equal([ + { type: 'text', value: '@[aabbcc]' }, + ]) + }) + + it('should handle adjacent mentions with no text between', function () { + const userId1 = 'aabbccddeeff00112233aabb' as UserId + const userId2 = '112233445566778899aabbcc' as UserId + expect(parseMentions(`@[${userId1}]@[${userId2}]`)).to.deep.equal([ + { type: 'mention', userId: userId1 }, + { type: 'mention', userId: userId2 }, + ]) + }) + + it('should handle a truncated mention as plain text', function () { + expect(parseMentions('@[aabbccddeeff001122')).to.deep.equal([ + { type: 'text', value: '@[aabbccddeeff001122' }, + ]) + }) +}) + +describe('sliceMentionSegments', function () { + const userId = 'aabbccddeeff00112233aabb' as UserId + + it('should return all segments when under the limit', function () { + const segments = parseMentions(`hi @[${userId}]`) + const result = sliceMentionSegments(segments, 100) + expect(result).to.deep.equal(segments) + }) + + it('should truncate a text segment at the limit', function () { + const segments = parseMentions('hello world') + const result = sliceMentionSegments(segments, 5) + expect(result).to.deep.equal([{ type: 'text', value: 'hello' }]) + }) + + it('should drop a mention that crosses the limit', function () { + const segments = parseMentions(`hey @[${userId}]`) + // "hey " = 4 chars, mention = 27 chars raw; limit 10 leaves only 6 for mention + const result = sliceMentionSegments(segments, 10) + expect(result).to.deep.equal([{ type: 'text', value: 'hey ' }]) + }) + + it('should include a mention that fits exactly', function () { + const segments = parseMentions(`hey @[${userId}]`) + // "hey " = 4 chars, mention = 27 chars; limit = 31 + const result = sliceMentionSegments(segments, 31) + expect(result).to.deep.equal(segments) + }) + + it('should return empty array when limit is 0', function () { + const segments = parseMentions('hello') + const result = sliceMentionSegments(segments, 0) + expect(result).to.deep.equal([]) + }) + + it('should handle a mention as the first segment', function () { + const segments = parseMentions(`@[${userId}] done`) + // mention = 27 chars; limit 27 fits the mention but not " done" + const result = sliceMentionSegments(segments, 27) + expect(result).to.deep.equal([{ type: 'mention', userId }]) + }) + + it('should drop a leading mention that does not fit', function () { + const segments = parseMentions(`@[${userId}] done`) + const result = sliceMentionSegments(segments, 10) + expect(result).to.deep.equal([]) + }) + + it('should handle multiple segments with truncation mid-text', function () { + const userId2 = '112233445566778899aabbcc' as UserId + const segments = parseMentions(`@[${userId}] and @[${userId2}]`) + // mention(27) + " and "(5) = 32; limit 35 leaves 3 chars but mention needs 27 + const result = sliceMentionSegments(segments, 35) + expect(result).to.deep.equal([ + { type: 'mention', userId }, + { type: 'text', value: ' and ' }, + ]) + }) +})