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
+2 -2
View File
@@ -179,8 +179,8 @@ PDF, text/log, office documents, RTF or archives; an unsupported file is refused
without naming the allowed types). After sending, the form clears and confirms "Ваше сообщение without naming the allowed types). After sending, the form clears and confirms "Ваше сообщение
отправлено", and sending is blocked until the operator has dealt with that message — on re-entry отправлено", and sending is blocked until the operator has dealt with that message — on re-entry
the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears
below the form as "Ответ на ваше последнее сообщение"; it is marked read once the screen shows it below the form as "Ответ на ваше последнее сообщение" (any link in it opens in a new tab); it is
and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an marked read once the screen shows it and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an
unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has
barred from feedback (a role, not a full account block) sees the send control disabled. barred from feedback (a role, not a full account block) sees the send control disabled.
+2 -2
View File
@@ -184,8 +184,8 @@ UTC), суточного окна отсутствия (away; сетка по 10
допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а
повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране
«Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как «Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как
«Ответ на ваше последнее сообщение»; он помечается прочитанным, как только экран его показал, и «Ответ на ваше последнее сообщение» (ссылки в нём открываются в новой вкладке); он помечается
исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном прочитанным, как только экран его показал, и исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной. обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
+5 -1
View File
@@ -43,7 +43,11 @@ test('feedback: an operator reply raises the badge and shows on the screen', asy
await openFeedback(page); await openFeedback(page);
await expect(page.getByText('Reply to your last message')).toBeVisible(); await expect(page.getByText('Reply to your last message')).toBeVisible();
await expect(page.getByText(/looking into it/)).toBeVisible(); // A URL in the reply renders as a link that opens in a new tab.
const link = page.getByRole('link', { name: 'https://example.com/help' });
await expect(link).toBeVisible();
await expect(link).toHaveAttribute('target', '_blank');
await expect(link).toHaveAttribute('rel', /noopener/);
}); });
test('feedback: sending a new message clears the previous reply', async ({ page }) => { test('feedback: sending a new message clears the previous reply', async ({ page }) => {
+54 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { attachmentError, MAX_ATTACHMENT_BYTES } from './feedback'; import { attachmentError, linkify, MAX_ATTACHMENT_BYTES } from './feedback';
describe('attachmentError', () => { describe('attachmentError', () => {
it('accepts allowed extensions within the size cap', () => { it('accepts allowed extensions within the size cap', () => {
@@ -22,3 +22,56 @@ describe('attachmentError', () => {
expect(attachmentError('shot.png', MAX_ATTACHMENT_BYTES + 1)).toBe('size'); 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'; if (size > MAX_ATTACHMENT_BYTES) return 'size';
return ''; 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 // 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: // reply and raises the badge via a live admin_reply notification. e2e hook:
// window.__mock.adminReply. // 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.feedbackPending = false;
this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) }; this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) };
this.feedbackReplyUnread = true; this.feedbackReplyUnread = true;
+7 -2
View File
@@ -6,7 +6,7 @@
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { clientChannel } from '../lib/channel'; import { clientChannel } from '../lib/channel';
import { attachmentError, MAX_BODY } from '../lib/feedback'; import { attachmentError, linkify, MAX_BODY } from '../lib/feedback';
import type { FeedbackState } from '../lib/model'; import type { FeedbackState } from '../lib/model';
// The feedback screen: a message (+ optional single attachment) the player sends to // The feedback screen: a message (+ optional single attachment) the player sends to
@@ -23,6 +23,8 @@
const enabled = $derived(view?.canSend ?? false); const enabled = $derived(view?.canSend ?? false);
const reason = $derived(view?.blockedReason ?? ''); const reason = $derived(view?.blockedReason ?? '');
const canSend = $derived(enabled && body.trim().length > 0 && !sending && connection.online); const canSend = $derived(enabled && body.trim().length > 0 && !sending && connection.online);
// The operator reply, split into text + http(s) links for rendering.
const replySegments = $derived(view?.reply ? linkify(view.reply.body) : []);
onMount(() => void load()); onMount(() => void load());
@@ -121,7 +123,7 @@
{#if view?.reply} {#if view?.reply}
<section class="reply"> <section class="reply">
<h2>{t('feedback.replyTitle')}</h2> <h2>{t('feedback.replyTitle')}</h2>
<p class="replybody">{view.reply.body}</p> <p class="replybody">{#each replySegments as seg, i (i)}{#if seg.href}<a href={seg.href} target="_blank" rel="noopener noreferrer">{seg.text}</a>{:else}{seg.text}{/if}{/each}</p>
</section> </section>
{/if} {/if}
</div> </div>
@@ -213,4 +215,7 @@
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.replybody a {
color: var(--accent);
}
</style> </style>