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 项断言全过,
含「中文句末标点未被包进链接」「裸域名不链接化」「点击真的开新标签页并导航到目标地址」。

Files changed (1) hide show
  1. 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 pattern = new RegExp(
142
- `@(${names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`,
143
- "g",
144
- );
 
 
 
145
  const parts: React.ReactNode[] = [];
146
  let cursor = 0;
147
- let match: RegExpExecArray | null;
148
  let key = 0;
149
- while ((match = pattern.exec(text)) !== null) {
150
- if (match.index > cursor) parts.push(text.slice(cursor, match.index));
151
- parts.push(
152
- <span key={`m${key++}`} className="rounded bg-sky-100 px-1 font-medium text-sky-700">
153
- @{match[1]}
154
- </span>,
155
- );
156
- cursor = match.index + match[0].length;
 
 
 
 
 
 
 
 
 
 
157
  }
158
- if (cursor < text.length) parts.push(text.slice(cursor));
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