(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 ' },
+ ])
+ })
+})