feat(feedback): render links in the operator reply (open in a new tab)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Linkify the operator reply on the feedback screen: http/https/ftp/mailto/tel URLs become anchors (target=_blank rel=noopener), everything else stays escaped text. A small whitelisted regex (no dependency) — the reply is operator-authored, so explicit schemes suffice; dangerous schemes (javascript:/data:) are never linked, and \b avoids matching a scheme inside a word (hotel:, email:).
This commit is contained in:
@@ -27,3 +27,42 @@ export function attachmentError(name: string, size: number): '' | 'type' | 'size
|
||||
if (size > MAX_ATTACHMENT_BYTES) return 'size';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** ReplySegment is one run of the operator reply: plain text, or a link when href is set. */
|
||||
export interface ReplySegment {
|
||||
text: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
// Links are recognised only for an explicit, whitelisted scheme, so dangerous ones
|
||||
// (javascript:, data:, vbscript:) are never turned into an href. http/https/ftp use
|
||||
// "scheme://host…"; mailto/tel use "scheme:rest". Matching stops at whitespace or '<'.
|
||||
// The reply is operator-authored, so explicit schemes (no bare-domain guessing) are
|
||||
// enough — which keeps this a few lines with no dependency.
|
||||
const URL_RE = /\b(?:(?:https?|ftp):\/\/|(?:mailto|tel):)[^\s<]+/gi;
|
||||
// Trailing punctuation that should not be part of the URL (kept as following text).
|
||||
const TRAILING_RE = /[.,!?;:)\]}'"»]+$/;
|
||||
|
||||
/**
|
||||
* linkify splits the operator reply into segments, marking http(s) URLs as links so
|
||||
* the UI renders them as anchors (opened in a new tab) while everything else stays
|
||||
* plain, escaped text. It never emits markup — the caller renders each segment with
|
||||
* the framework's escaping — so it cannot introduce XSS.
|
||||
*/
|
||||
export function linkify(text: string): ReplySegment[] {
|
||||
const out: ReplySegment[] = [];
|
||||
let last = 0;
|
||||
for (const m of text.matchAll(URL_RE)) {
|
||||
const start = m.index;
|
||||
let url = m[0];
|
||||
const trail = TRAILING_RE.exec(url)?.[0] ?? '';
|
||||
if (trail) url = url.slice(0, url.length - trail.length);
|
||||
if (start > last) out.push({ text: text.slice(last, start) });
|
||||
out.push({ text: url, href: url });
|
||||
if (trail) out.push({ text: trail });
|
||||
last = start + m[0].length;
|
||||
}
|
||||
if (last < text.length) out.push({ text: text.slice(last) });
|
||||
if (out.length === 0) out.push({ text });
|
||||
return out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user