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

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:
Ilia Denisov
2026-06-15 13:25:36 +02:00
parent 55ed87fb11
commit 277954c47f
7 changed files with 110 additions and 9 deletions
+54 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { attachmentError, MAX_ATTACHMENT_BYTES } from './feedback';
import { attachmentError, linkify, MAX_ATTACHMENT_BYTES } from './feedback';
describe('attachmentError', () => {
it('accepts allowed extensions within the size cap', () => {
@@ -22,3 +22,56 @@ describe('attachmentError', () => {
expect(attachmentError('shot.png', MAX_ATTACHMENT_BYTES + 1)).toBe('size');
});
});
describe('linkify', () => {
it('returns a single text segment for plain text', () => {
expect(linkify('just text')).toEqual([{ text: 'just text' }]);
});
it('marks an http(s) URL as a link', () => {
expect(linkify('see https://example.com now')).toEqual([
{ text: 'see ' },
{ text: 'https://example.com', href: 'https://example.com' },
{ text: ' now' },
]);
});
it('keeps trailing punctuation out of the link', () => {
expect(linkify('go to https://example.com.')).toEqual([
{ text: 'go to ' },
{ text: 'https://example.com', href: 'https://example.com' },
{ text: '.' },
]);
expect(linkify('(see https://example.com/path)')).toEqual([
{ text: '(see ' },
{ text: 'https://example.com/path', href: 'https://example.com/path' },
{ text: ')' },
]);
});
it('handles multiple links', () => {
const segs = linkify('a http://x.io b https://y.io');
expect(segs.filter((s) => s.href).map((s) => s.href)).toEqual(['http://x.io', 'https://y.io']);
});
it('linkifies whitelisted non-http schemes (ftp, mailto, tel)', () => {
expect(linkify('ftp://files.example.com/x')).toContainEqual({
text: 'ftp://files.example.com/x',
href: 'ftp://files.example.com/x',
});
expect(linkify('write to mailto:help@example.com please')).toContainEqual({
text: 'mailto:help@example.com',
href: 'mailto:help@example.com',
});
expect(linkify('call tel:+1234')).toContainEqual({ text: 'tel:+1234', href: 'tel:+1234' });
});
it('never linkifies dangerous schemes', () => {
const segs = linkify('javascript:alert(1) data:text/html,x vbscript:msgbox');
expect(segs.some((s) => s.href)).toBe(false);
});
it('does not match a scheme inside a word (hotel:, email:)', () => {
expect(linkify('the hotel:foo and email:bar').some((s) => s.href)).toBe(false);
});
});
+39
View File
@@ -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;
}
+1 -1
View File
@@ -435,7 +435,7 @@ export class MockGateway implements GatewayClient {
// mockAdminReply simulates an operator reply: it clears the pending message, sets the
// reply and raises the badge via a live admin_reply notification. e2e hook:
// window.__mock.adminReply.
mockAdminReply(body = 'Thanks — we are looking into it.'): void {
mockAdminReply(body = 'Thanks — see https://example.com/help for details.'): void {
this.feedbackPending = false;
this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) };
this.feedbackReplyUnread = true;