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:
+2
-2
@@ -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 "Ваше сообщение
|
||||
отправлено", and sending is blocked until the operator has dealt with that message — on re-entry
|
||||
the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears
|
||||
below the form as "Ответ на ваше последнее сообщение"; it is 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
|
||||
below the form as "Ответ на ваше последнее сообщение" (any link in it opens in a new tab); it is
|
||||
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
|
||||
barred from feedback (a role, not a full account block) sees the send control disabled.
|
||||
|
||||
|
||||
@@ -184,8 +184,8 @@ UTC), суточного окна отсутствия (away; сетка по 10
|
||||
допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а
|
||||
повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране
|
||||
«Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как
|
||||
«Ответ на ваше последнее сообщение»; он помечается прочитанным, как только экран его показал, и
|
||||
исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном
|
||||
«Ответ на ваше последнее сообщение» (ссылки в нём открываются в новой вкладке); он помечается
|
||||
прочитанным, как только экран его показал, и исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном
|
||||
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
|
||||
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
||||
|
||||
|
||||
@@ -43,7 +43,11 @@ test('feedback: an operator reply raises the badge and shows on the screen', asy
|
||||
|
||||
await openFeedback(page);
|
||||
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 }) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
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';
|
||||
|
||||
// The feedback screen: a message (+ optional single attachment) the player sends to
|
||||
@@ -23,6 +23,8 @@
|
||||
const enabled = $derived(view?.canSend ?? false);
|
||||
const reason = $derived(view?.blockedReason ?? '');
|
||||
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());
|
||||
|
||||
@@ -121,7 +123,7 @@
|
||||
{#if view?.reply}
|
||||
<section class="reply">
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -213,4 +215,7 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.replybody a {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user