xiaolongmr commited on
Commit ·
4ec754b
1
Parent(s): d9165c3
聊天室:消息里的 URL 自动变成可点击链接(新标签页打开)
Browse files- 改造 `renderContent`:原来只处理 @昵称 高亮,现在 @高亮 与 URL 链接化并存。
非 @ 片段再走一遍链接识别,两套 pattern 不互相干扰。
- ⚠️⚠️ **URL 正则必须把中文标点排除在字符类外**:
中文没有空格,`https://example.org。以及后面的话` 若不在 `。` 处停下,
会把整个句子吞进链接里 —— 真机实测踩到,第一版就是错的。
同时 ASCII 尾部标点(`)]}>.,;:!?`)在用 trimUrlTail 剥掉。
- ⚠️ 故意**不**匹配裸域名(`abc.com`):中文聊天里「图片.png」「3.5 折」极易误判。
- ⚠️ `target="_blank"` 必须配 `rel="noopener noreferrer"`,否则新页面能通过
`window.opener` 反向操作当前页。
- ⚠️ 链接 `onClick` 要 `stopPropagation()`:消息区外层有点击处理。
验证:解析逻辑 11 例单测 + 真机(Playwright)8 项断言全过,
含「中文句末标点未被包进链接」「裸域名不链接化」「点击真的开新标签页并导航到目标地址」。
- web/src/app/chat-room/page.tsx +81 -17
web/src/app/chat-room/page.tsx
CHANGED
|
@@ -127,35 +127,99 @@ function Avatar({
|
|
| 127 |
);
|
| 128 |
}
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
/**
|
| 131 |
-
* 把消息正文按 @昵称 切成片段,命中的部分高亮。
|
| 132 |
*
|
| 133 |
* ⚠️ 只做**展示层**高亮,不改动存储的文本:这样消息永远是纯文本,复制 / 搜索都不会带样式,
|
| 134 |
* 也不会因为谁改了昵称导致历史消息渲染错乱。
|
| 135 |
*/
|
| 136 |
function renderContent(text: string, mentionNames: string[]) {
|
| 137 |
if (!text) return null;
|
| 138 |
-
if (mentionNames.length === 0) return text;
|
| 139 |
-
// 长昵称优先匹配,避免「@小张」被「@小」抢先截断。
|
| 140 |
const names = [...new Set(mentionNames.filter(Boolean))].sort((a, b) => b.length - a.length);
|
| 141 |
-
const
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
| 145 |
const parts: React.ReactNode[] = [];
|
| 146 |
let cursor = 0;
|
| 147 |
-
let match: RegExpExecArray | null;
|
| 148 |
let key = 0;
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
}
|
| 158 |
-
if (cursor < text.length)
|
| 159 |
return parts.length > 0 ? parts : text;
|
| 160 |
}
|
| 161 |
|
|
|
|
| 127 |
);
|
| 128 |
}
|
| 129 |
|
| 130 |
+
// URL 匹配:只认带协议的 http(s) 链接。
|
| 131 |
+
// ⚠️ 故意**不**匹配裸域名(如 `abc.com`)—— 中文聊天里「图片.png」「3.5 折」这类
|
| 132 |
+
// 文本极易被误判成链接,凭肉眼分不清的宁可不当链接。
|
| 133 |
+
// ⚠️⚠️ 必须把**中文标点也排除在字符类之外**:中文没有空格,写
|
| 134 |
+
// `https://example.org。以及后面的话` 时,若不在 `。` 处停下会把整句都吞进链接里。
|
| 135 |
+
const URL_PATTERN = /https?:\/\/[^\s<>"',。!?;:、()【】《》“”‘’]+/g;
|
| 136 |
+
|
| 137 |
+
/** 链接末尾的 ASCII 标点通常属于句子而非 URL,也要剥掉。 */
|
| 138 |
+
function trimUrlTail(url: string) {
|
| 139 |
+
let end = url;
|
| 140 |
+
let tail = "";
|
| 141 |
+
const trailing = /[)\]}>.,;:!?]+$/;
|
| 142 |
+
const match = trailing.exec(end);
|
| 143 |
+
if (match) {
|
| 144 |
+
tail = match[0];
|
| 145 |
+
end = end.slice(0, match.index);
|
| 146 |
+
}
|
| 147 |
+
return { url: end, tail };
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/** 单行文本里把 URL 变成可点击的新标签页链接。 */
|
| 151 |
+
function renderLinks(text: string, keyBase: string): React.ReactNode[] {
|
| 152 |
+
const parts: React.ReactNode[] = [];
|
| 153 |
+
let cursor = 0;
|
| 154 |
+
let match: RegExpExecArray | null;
|
| 155 |
+
let key = 0;
|
| 156 |
+
URL_PATTERN.lastIndex = 0;
|
| 157 |
+
while ((match = URL_PATTERN.exec(text)) !== null) {
|
| 158 |
+
const { url, tail } = trimUrlTail(match[0]);
|
| 159 |
+
if (!url || url.length <= "https://".length) continue;
|
| 160 |
+
if (match.index > cursor) parts.push(text.slice(cursor, match.index));
|
| 161 |
+
parts.push(
|
| 162 |
+
<a
|
| 163 |
+
key={`${keyBase}-u${key++}`}
|
| 164 |
+
href={url}
|
| 165 |
+
target="_blank"
|
| 166 |
+
// ⚠️ 必须带 noopener:否则新页面能通过 window.opener 反向操作当前页。
|
| 167 |
+
rel="noopener noreferrer"
|
| 168 |
+
className="break-all font-medium text-sky-600 underline underline-offset-2 hover:text-sky-700"
|
| 169 |
+
onClick={(event) => {
|
| 170 |
+
// ⚠️ 消息区外层有点击处理(且这行在可点区域内),不拦住会连带触发别处逻辑。
|
| 171 |
+
event.stopPropagation();
|
| 172 |
+
}}
|
| 173 |
+
>
|
| 174 |
+
{url}
|
| 175 |
+
</a>,
|
| 176 |
+
);
|
| 177 |
+
if (tail) parts.push(tail);
|
| 178 |
+
cursor = match.index + match[0].length;
|
| 179 |
+
}
|
| 180 |
+
if (cursor < text.length) parts.push(text.slice(cursor));
|
| 181 |
+
return parts;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
/**
|
| 185 |
+
* 把消息正文按 @昵称 切成片段,命中的部分高亮;URL 变成可点击链接。
|
| 186 |
*
|
| 187 |
* ⚠️ 只做**展示层**高亮,不改动存储的文本:这样消息永远是纯文本,复制 / 搜索都不会带样式,
|
| 188 |
* 也不会因为谁改了昵称导致历史消息渲染错乱。
|
| 189 |
*/
|
| 190 |
function renderContent(text: string, mentionNames: string[]) {
|
| 191 |
if (!text) return null;
|
|
|
|
|
|
|
| 192 |
const names = [...new Set(mentionNames.filter(Boolean))].sort((a, b) => b.length - a.length);
|
| 193 |
+
const hasMentionPattern = names.length > 0;
|
| 194 |
+
const pattern = hasMentionPattern
|
| 195 |
+
? new RegExp(
|
| 196 |
+
`@(${names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`,
|
| 197 |
+
"g",
|
| 198 |
+
)
|
| 199 |
+
: null;
|
| 200 |
const parts: React.ReactNode[] = [];
|
| 201 |
let cursor = 0;
|
|
|
|
| 202 |
let key = 0;
|
| 203 |
+
|
| 204 |
+
/** 非 @ 片段再走一遍 URL 识别,返回统一 key 的节点数组。 */
|
| 205 |
+
const pushPlain = (chunk: string) => {
|
| 206 |
+
if (!chunk) return;
|
| 207 |
+
parts.push(...renderLinks(chunk, `p${key++}`));
|
| 208 |
+
};
|
| 209 |
+
|
| 210 |
+
if (pattern) {
|
| 211 |
+
let match: RegExpExecArray | null;
|
| 212 |
+
while ((match = pattern.exec(text)) !== null) {
|
| 213 |
+
if (match.index > cursor) pushPlain(text.slice(cursor, match.index));
|
| 214 |
+
parts.push(
|
| 215 |
+
<span key={`m${key++}`} className="rounded bg-sky-100 px-1 font-medium text-sky-700">
|
| 216 |
+
@{match[1]}
|
| 217 |
+
</span>,
|
| 218 |
+
);
|
| 219 |
+
cursor = match.index + match[0].length;
|
| 220 |
+
}
|
| 221 |
}
|
| 222 |
+
if (cursor < text.length) pushPlain(text.slice(cursor));
|
| 223 |
return parts.length > 0 ? parts : text;
|
| 224 |
}
|
| 225 |
|