mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[web] parse mentions in comments (#34055)
GitOrigin-RevId: 185485a3ac9670c46d0e7c43bf281a5ffee3f784
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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 ' },
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user