فهرست منبع

[web] parse mentions in comments (#34055)

GitOrigin-RevId: 185485a3ac9670c46d0e7c43bf281a5ffee3f784
Kristina 2 ماه پیش
والد
کامیت
eb031f4c3c

+ 1 - 0
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',

+ 28 - 0
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 <span className="review-panel-mention">@{t('unknown')}</span>
+  }
+
+  return (
+    <OLTooltip
+      id={`mention-${id}`}
+      description={user.email}
+      overlayProps={{ placement: 'bottom' }}
+      tooltipProps={{ className: 'review-panel-tooltip' }}
+    >
+      <span className="review-panel-mention">@{buildName(user)}</span>
+    </OLTooltip>
+  )
+}

+ 34 - 2
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<HTMLDivElement>(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 && '...'}
       </div>
       <div
@@ -88,6 +109,17 @@ export const ExpandableContent = memo<{
   )
 })
 
+function renderSegments(segments: MentionSegment[]) {
+  return segments.map((segment, i) => {
+    if (segment.type === 'mention') {
+      return (
+        <MentionBadge key={`${i}-${segment.userId}`} userId={segment.userId} />
+      )
+    }
+    return segment.value
+  })
+}
+
 function indexOfNthLine(content: string, n: number) {
   if (n < 1) return null
 

+ 5 - 0
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}
         />
       )}
 

+ 61 - 0
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
+}

+ 4 - 0
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);

+ 78 - 0
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(
+    <ChangesUsersContext.Provider value={changesUsers}>
+      <MentionBadge userId={userId} />
+    </ChangesUsersContext.Provider>
+  )
+}
+
+describe('<MentionBadge />', 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
+  })
+})

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