Promote development → master (initial production release: pre-release line + Stage 18) #104

Merged
developer merged 307 commits from development into master 2026-06-22 05:05:48 +00:00
7 changed files with 110 additions and 9 deletions
Showing only changes of commit 277954c47f - Show all commits
+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 "Ваше сообщение
отправлено", 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.
+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 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 }) => {
+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;
+7 -2
View File
@@ -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>