diff --git a/ui/src/components/Comment/index.tsx b/ui/src/components/Comment/index.tsx index af2ddbd4f..fcb035cca 100644 --- a/ui/src/components/Comment/index.tsx +++ b/ui/src/components/Comment/index.tsx @@ -114,10 +114,18 @@ const Comment: FC = ({ objectId, mode, commentId, children }) => { displayName: item.user_display_name, userName: item.username, }); - user.push({ - id: item.reply_comment_id, - displayName: item.reply_user_display_name, - userName: item.username, + if (item.reply_username) { + user.push({ + id: item.reply_user_id, + displayName: item.reply_user_display_name, + userName: item.reply_username, + }); + } + matchedUsers(item.original_text).forEach((mention) => { + user.push({ + displayName: mention.userName, + userName: mention.userName, + }); }); }); pageUsers.setUsers(user); @@ -225,9 +233,10 @@ const Comment: FC = ({ objectId, mode, commentId, children }) => { }; const handleSendReply = (item) => { - const users = matchedUsers(item.value); + const knownUsers = pageUsers.getUsers(); + const users = matchedUsers(item.value, knownUsers); const userNames = unionBy(users.map((user) => user.userName)); - const commentMarkDown = parseUserInfo(item.value); + const commentMarkDown = parseUserInfo(item.value, knownUsers); const params = { object_id: objectId, diff --git a/ui/src/components/Mentions/index.tsx b/ui/src/components/Mentions/index.tsx index 662ffc974..f4124d273 100644 --- a/ui/src/components/Mentions/index.tsx +++ b/ui/src/components/Mentions/index.tsx @@ -21,6 +21,7 @@ import React, { useEffect, useRef, useState, FC } from 'react'; import { Dropdown } from 'react-bootstrap'; import { useSearchUserStaff } from '@/services'; +import usePageUsers from '@/hooks/usePageUsers'; import * as Types from '@/common/interface'; import './index.scss'; @@ -40,6 +41,7 @@ const Mentions: FC = ({ children, pageUsers, onSelected }) => { const [users, setUsers] = useState([]); const [cursor, setCursor] = useState(0); const [isRequested, setRequestedState] = useState(false); + const { setUsers: savePageUsers } = usePageUsers(); const { data: staffUserList = [] } = useSearchUserStaff(val); const mapStaffUsers = staffUserList @@ -107,6 +109,9 @@ const Mentions: FC = ({ children, pageUsers, onSelected }) => { return; } + if (item?.userName) { + savePageUsers(item); + } const text = `@${item?.userName} `; onSelected( `${value.substring( diff --git a/ui/src/hooks/usePageUsers/index.tsx b/ui/src/hooks/usePageUsers/index.tsx index c0b3836e6..7b53b1ad1 100644 --- a/ui/src/hooks/usePageUsers/index.tsx +++ b/ui/src/hooks/usePageUsers/index.tsx @@ -27,7 +27,7 @@ let globalUsers: Types.PageUser[] = []; const usePageUsers = () => { const [users, setUsers] = useState(globalUsers); const getUsers = () => { - return users; + return uniqBy([...users, ...globalUsers], 'userName'); }; return { getUsers, diff --git a/ui/src/pages/Review/components/EditPostModal/index.tsx b/ui/src/pages/Review/components/EditPostModal/index.tsx index 5a4529fda..b9c5eb194 100644 --- a/ui/src/pages/Review/components/EditPostModal/index.tsx +++ b/ui/src/pages/Review/components/EditPostModal/index.tsx @@ -28,7 +28,7 @@ import { usePageUsers } from '@/hooks'; import { useCaptchaPlugin } from '@/utils/pluginKit'; import { Editor, TagSelector, Mentions, TextArea } from '@/components'; import { - // matchedUsers, + matchedUsers, parseUserInfo, handleFormError, parseEditMentionUser, @@ -187,7 +187,10 @@ const Index: FC = ({ const { value } = formData.content; // const users = matchedUsers(value); // const userNames = unionBy(users.map((user) => user.userName)); - const commentMarkDown = parseUserInfo(value); + const commentMarkDown = parseUserInfo(value, [ + ...pageUsers.getUsers(), + ...matchedUsers(originalData.content), + ]); // params.mention_username_list = userNames; params.content = commentMarkDown; diff --git a/ui/src/utils/common.ts b/ui/src/utils/common.ts index 930392c18..0a1454fe0 100644 --- a/ui/src/utils/common.ts +++ b/ui/src/utils/common.ts @@ -23,6 +23,7 @@ import * as Diff from 'diff'; import pattern from '@/common/pattern'; import { USER_AGENT_NAMES } from '@/common/constants'; import type * as Type from '@/common/interface'; +import { matchedUsers, parseUserInfo, parseEditMentionUser } from './mention'; function thousandthDivision(num) { const reg = /\d{1,3}(?=(\d{3})+$)/g; @@ -89,42 +90,6 @@ const bgFadeOut = (el) => { } }; -/** - * Extract user info from markdown - * @param markdown string - * @returns Array<{displayName: string, userName: string}> - */ -function matchedUsers(markdown) { - const globalReg = /\B@([\w|]+)/g; - const reg = /\B@([\w\\_\\.]+)/; - - const users = markdown.match(globalReg); - if (!users) { - return []; - } - return users.map((user) => { - const matched = user.match(reg); - return { - userName: matched[1], - }; - }); -} - -/** - * Identify user information from markdown - * @param markdown string - * @returns string - */ -function parseUserInfo(markdown) { - const globalReg = /\B@([\w\\_\\.\\-]+)/g; - return markdown.replace(globalReg, '[@$1](/users/$1)'); -} - -function parseEditMentionUser(markdown) { - const globalReg = /\[@([^\]]+)\]\([^)]+\)/g; - return markdown.replace(globalReg, '@$1'); -} - function formatUptime(value) { const t = i18next.t.bind(i18next); const second = parseInt(value, 10); diff --git a/ui/src/utils/mention.test.cjs b/ui/src/utils/mention.test.cjs new file mode 100644 index 000000000..a99f7a483 --- /dev/null +++ b/ui/src/utils/mention.test.cjs @@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const Module = require('node:module'); +const test = require('node:test'); + +let transpileTypeScript; +try { + const ts = require('typescript'); + transpileTypeScript = (source, filename) => + ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + fileName: filename, + }).outputText; +} catch { + transpileTypeScript = (source) => { + const stripped = Module.stripTypeScriptTypes(source); + return stripped.replace( + /export\s*\{\s*([^}]+)\s*\};?/g, + 'module.exports = { $1 };', + ); + }; +} + +const previousTypeScriptLoader = require.extensions['.ts']; +require.extensions['.ts'] = (mod, filename) => { + const source = fs.readFileSync(filename, 'utf8'); + const output = transpileTypeScript(source, filename); + mod._compile(output, filename); +}; + +let matchedUsers; +let parseUserInfo; +let parseEditMentionUser; +try { + ({ matchedUsers, parseUserInfo, parseEditMentionUser } = require('./mention.ts')); +} finally { + if (previousTypeScriptLoader) { + require.extensions['.ts'] = previousTypeScriptLoader; + } else { + delete require.extensions['.ts']; + } +} + +test('matches usernames containing dots, hyphens, and underscores', () => { + const markdown = 'cc @jane-doe and @john.doe and @dev_user-1.2'; + + assert.deepEqual(matchedUsers(markdown), [ + { userName: 'jane-doe' }, + { userName: 'john.doe' }, + { userName: 'dev_user-1.2' }, + ]); + assert.equal( + parseUserInfo(markdown), + 'cc [@jane-doe](/users/jane-doe) and [@john.doe](/users/john.doe) and [@dev_user-1.2](/users/dev_user-1.2)', + ); +}); + +test('treats trailing sentence period as punctuation unless username with trailing dot is known', () => { + const sentence = 'Thanks @john.'; + + assert.deepEqual(matchedUsers(sentence), [{ userName: 'john' }]); + assert.equal(parseUserInfo(sentence), 'Thanks [@john](/users/john).'); + + const knownUsers = [{ userName: 'john.' }]; + assert.deepEqual(matchedUsers(sentence, knownUsers), [{ userName: 'john.' }]); + assert.equal( + parseUserInfo(sentence, knownUsers), + 'Thanks [@john.](/users/john.)', + ); + + const sentenceWithKnownTrailingDot = 'Thanks @john..'; + assert.deepEqual(matchedUsers(sentenceWithKnownTrailingDot, knownUsers), [ + { userName: 'john.' }, + ]); + assert.equal( + parseUserInfo(sentenceWithKnownTrailingDot, knownUsers), + 'Thanks [@john.](/users/john.).', + ); +}); + +test('excludes trailing punctuation from both notification targets and rendered links', () => { + const markdown = + 'Ping @john.doe., @jane-doe, @alice! (@bob?) @carol...'; + + assert.deepEqual(matchedUsers(markdown), [ + { userName: 'john.doe' }, + { userName: 'jane-doe' }, + { userName: 'alice' }, + { userName: 'bob' }, + { userName: 'carol' }, + ]); + assert.equal( + parseUserInfo(markdown), + 'Ping [@john.doe](/users/john.doe)., [@jane-doe](/users/jane-doe), [@alice](/users/alice)! ([@bob](/users/bob)?) [@carol](/users/carol)...', + ); +}); + +test('stops before invalid username characters and ignores non-mentions', () => { + assert.deepEqual(matchedUsers('@john\\doe'), [{ userName: 'john' }]); + assert.equal(parseUserInfo('@john\\doe'), '[@john](/users/john)\\doe'); + + assert.deepEqual(matchedUsers('@john|doe'), [{ userName: 'john' }]); + assert.equal(parseUserInfo('@john|doe'), '[@john](/users/john)|doe'); + + assert.deepEqual(matchedUsers('@\\doe @! @. @... user@example.com'), []); + assert.equal( + parseUserInfo('@\\doe @! @. @... user@example.com'), + '@\\doe @! @. @... user@example.com', + ); +}); + +test('handles multiple mentions and already-rendered mention links consistently', () => { + const knownUsers = [{ userName: 'ops.lead.' }]; + const markdown = + '@jane-doe, please check with @ops.lead. and [@john.](/users/john.) about @john.doe.'; + + assert.deepEqual(matchedUsers(markdown, knownUsers), [ + { userName: 'jane-doe' }, + { userName: 'ops.lead.' }, + { userName: 'john.' }, + { userName: 'john.doe' }, + ]); + const rendered = parseUserInfo(markdown, knownUsers); + assert.equal( + rendered, + '[@jane-doe](/users/jane-doe), please check with [@ops.lead.](/users/ops.lead.) and [@john.](/users/john.) about [@john.doe](/users/john.doe).', + ); + assert.equal( + parseEditMentionUser(rendered), + '@jane-doe, please check with @ops.lead. and @john. about @john.doe.', + ); +}); diff --git a/ui/src/utils/mention.ts b/ui/src/utils/mention.ts new file mode 100644 index 000000000..60a2df43a --- /dev/null +++ b/ui/src/utils/mention.ts @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +type KnownUser = { userName?: string } | string; + +const MENTION_REG = /\B@([\w.-]+)/g; + +function toKnownUserSet(knownUsers: KnownUser[] = []): Set { + const set = new Set(); + knownUsers.forEach((user) => { + const userName = typeof user === 'string' ? user : user?.userName; + if (userName) { + set.add(userName); + } + }); + return set; +} + +function isRenderedMentionLink( + source: string, + offset: number, + matchLength: number, +): boolean { + return ( + offset > 0 && + source[offset - 1] === '[' && + source.slice(offset + matchLength).startsWith('](/users/') + ); +} + +function resolveMentionUsername( + raw: string, + knownUserSet: Set, +): string { + let candidate = raw; + while (candidate.endsWith('.') && !knownUserSet.has(candidate)) { + candidate = candidate.slice(0, -1); + } + return candidate; +} + +/** + * Extract user info from markdown + * @param markdown string + * @param knownUsers Array<{ userName?: string } | string> + * @returns Array<{userName: string}> + */ +function matchedUsers(markdown = '', knownUsers: KnownUser[] = []) { + if (!markdown) { + return []; + } + const knownUserSet = toKnownUserSet(knownUsers); + const users: Array<{ userName: string }> = []; + + markdown.replace( + MENTION_REG, + (match: string, raw: string, offset: number, source: string) => { + const userName = isRenderedMentionLink(source, offset, match.length) + ? raw + : resolveMentionUsername(raw, knownUserSet); + if (userName) { + users.push({ userName }); + } + return match; + }, + ); + + return users; +} + +/** + * Identify user information from markdown + * @param markdown string + * @param knownUsers Array<{ userName?: string } | string> + * @returns string + */ +function parseUserInfo(markdown = '', knownUsers: KnownUser[] = []) { + if (!markdown) { + return ''; + } + const knownUserSet = toKnownUserSet(knownUsers); + + return markdown.replace( + MENTION_REG, + (match: string, raw: string, offset: number, source: string) => { + if (isRenderedMentionLink(source, offset, match.length)) { + return match; + } + const userName = resolveMentionUsername(raw, knownUserSet); + if (!userName) { + return match; + } + const trailing = raw.slice(userName.length); + return `[@${userName}](/users/${userName})${trailing}`; + }, + ); +} + +function parseEditMentionUser(markdown) { + const globalReg = /\[@([^\]]+)\]\([^)]+\)/g; + return markdown.replace(globalReg, '@$1'); +} + +export { matchedUsers, parseUserInfo, parseEditMentionUser };