feat(feedback): in-app user feedback with admin review and account roles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s

User-facing Feedback screen (Settings -> Info, registered accounts only): a
message (<=1024 runes) plus one optional attachment, an anti-spam gate (one
unreviewed message at a time), and the operator's inline reply with a
Settings/Info badge. Server-rendered admin console section (/_gm/feedback):
unread/read/archived queue with per-user search, detail with read/reply/
archive/delete/delete-all, safe attachment serving (nosniff, images inline via
<img>, others download-only). Introduces account_roles, the first per-account
role table; feedback_banned blocks only feedback submission, granted/revoked
from /users and the delete-with-block action.

- migration 00004_feedback (feedback_messages + account_roles) + jetgen
- backend internal/feedback (store+service), internal/account/roles.go
- wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest,
  is_guest via session resolve) -> guest_forbidden before any backend call
- reply push reuses NotificationEvent with a new admin_reply sub-kind
- UI: /feedback route + screen, attachment picker, badge, channel detection, i18n
- tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e
- docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs
This commit is contained in:
Ilia Denisov
2026-06-15 12:23:10 +02:00
parent fc848157d6
commit 419ea11b14
75 changed files with 3638 additions and 55 deletions
+7 -3
View File
@@ -13,6 +13,7 @@
import Stats from './screens/Stats.svelte';
import Game from './game/Game.svelte';
import CommsHub from './game/CommsHub.svelte';
import Feedback from './screens/Feedback.svelte';
import Blocked from './screens/Blocked.svelte';
onMount(() => {
@@ -26,8 +27,9 @@
if (!insideTelegram()) return;
const r = router.route;
// The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
const sub = r.name === 'gameChat' || r.name === 'gameCheck';
const target = sub ? `/game/${r.params.id}` : '/';
let target = '/';
if (r.name === 'gameChat' || r.name === 'gameCheck') target = `/game/${r.params.id}`;
else if (r.name === 'feedback') target = '/about'; // back to the Settings → Info tab
telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
});
@@ -40,7 +42,7 @@
// back-to-the-game the wrong way. dir is read with the previous depth (the effect updates it
// only after the transition has captured its sign).
function routeDepth(name: RouteName): number {
if (name === 'gameChat' || name === 'gameCheck') return 2;
if (name === 'gameChat' || name === 'gameCheck' || name === 'feedback') return 2;
if (name === 'lobby' || name === 'login') return 0;
return 1;
}
@@ -93,6 +95,8 @@
<SettingsHub initialTab="about" />
{:else if router.route.name === 'friends'}
<SettingsHub initialTab="friends" />
{:else if router.route.name === 'feedback'}
<Feedback />
{:else if router.route.name === 'stats'}
<Stats />
{:else}
+4
View File
@@ -19,6 +19,10 @@ export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
export { EvalRequest } from './scrabblefb/eval-request.js';
export { EvalResult } from './scrabblefb/eval-result.js';
export { ExchangeRequest } from './scrabblefb/exchange-request.js';
export { FeedbackReply } from './scrabblefb/feedback-reply.js';
export { FeedbackState } from './scrabblefb/feedback-state.js';
export { FeedbackSubmitRequest } from './scrabblefb/feedback-submit-request.js';
export { FeedbackUnread } from './scrabblefb/feedback-unread.js';
export { FriendCode } from './scrabblefb/friend-code.js';
export { FriendList } from './scrabblefb/friend-list.js';
export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js';
@@ -0,0 +1,58 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class FeedbackReply {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FeedbackReply {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFeedbackReply(bb:flatbuffers.ByteBuffer, obj?:FeedbackReply):FeedbackReply {
return (obj || new FeedbackReply()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFeedbackReply(bb:flatbuffers.ByteBuffer, obj?:FeedbackReply):FeedbackReply {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FeedbackReply()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
body():string|null
body(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
body(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
repliedAtUnix():bigint {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
}
static startFeedbackReply(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, bodyOffset, 0);
}
static addRepliedAtUnix(builder:flatbuffers.Builder, repliedAtUnix:bigint) {
builder.addFieldInt64(1, repliedAtUnix, BigInt('0'));
}
static endFeedbackReply(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFeedbackReply(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, repliedAtUnix:bigint):flatbuffers.Offset {
FeedbackReply.startFeedbackReply(builder);
FeedbackReply.addBody(builder, bodyOffset);
FeedbackReply.addRepliedAtUnix(builder, repliedAtUnix);
return FeedbackReply.endFeedbackReply(builder);
}
}
@@ -0,0 +1,64 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { FeedbackReply } from '../scrabblefb/feedback-reply.js';
export class FeedbackState {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FeedbackState {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFeedbackState(bb:flatbuffers.ByteBuffer, obj?:FeedbackState):FeedbackState {
return (obj || new FeedbackState()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFeedbackState(bb:flatbuffers.ByteBuffer, obj?:FeedbackState):FeedbackState {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FeedbackState()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
canSend():boolean {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
blockedReason():string|null
blockedReason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
blockedReason(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
reply(obj?:FeedbackReply):FeedbackReply|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? (obj || new FeedbackReply()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
static startFeedbackState(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addCanSend(builder:flatbuffers.Builder, canSend:boolean) {
builder.addFieldInt8(0, +canSend, +false);
}
static addBlockedReason(builder:flatbuffers.Builder, blockedReasonOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, blockedReasonOffset, 0);
}
static addReply(builder:flatbuffers.Builder, replyOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, replyOffset, 0);
}
static endFeedbackState(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
}
@@ -0,0 +1,104 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class FeedbackSubmitRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FeedbackSubmitRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFeedbackSubmitRequest(bb:flatbuffers.ByteBuffer, obj?:FeedbackSubmitRequest):FeedbackSubmitRequest {
return (obj || new FeedbackSubmitRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFeedbackSubmitRequest(bb:flatbuffers.ByteBuffer, obj?:FeedbackSubmitRequest):FeedbackSubmitRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FeedbackSubmitRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
body():string|null
body(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
body(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
attachment(index: number):number|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
}
attachmentLength():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
attachmentArray():Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
}
attachmentName():string|null
attachmentName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
attachmentName(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
channel():string|null
channel(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
channel(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startFeedbackSubmitRequest(builder:flatbuffers.Builder) {
builder.startObject(4);
}
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, bodyOffset, 0);
}
static addAttachment(builder:flatbuffers.Builder, attachmentOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, attachmentOffset, 0);
}
static createAttachmentVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
builder.startVector(1, data.length, 1);
for (let i = data.length - 1; i >= 0; i--) {
builder.addInt8(data[i]!);
}
return builder.endVector();
}
static startAttachmentVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(1, numElems, 1);
}
static addAttachmentName(builder:flatbuffers.Builder, attachmentNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, attachmentNameOffset, 0);
}
static addChannel(builder:flatbuffers.Builder, channelOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, channelOffset, 0);
}
static endFeedbackSubmitRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset):flatbuffers.Offset {
FeedbackSubmitRequest.startFeedbackSubmitRequest(builder);
FeedbackSubmitRequest.addBody(builder, bodyOffset);
FeedbackSubmitRequest.addAttachment(builder, attachmentOffset);
FeedbackSubmitRequest.addAttachmentName(builder, attachmentNameOffset);
FeedbackSubmitRequest.addChannel(builder, channelOffset);
return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder);
}
}
@@ -0,0 +1,46 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class FeedbackUnread {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):FeedbackUnread {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsFeedbackUnread(bb:flatbuffers.ByteBuffer, obj?:FeedbackUnread):FeedbackUnread {
return (obj || new FeedbackUnread()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsFeedbackUnread(bb:flatbuffers.ByteBuffer, obj?:FeedbackUnread):FeedbackUnread {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new FeedbackUnread()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
replyUnread():boolean {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startFeedbackUnread(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addReplyUnread(builder:flatbuffers.Builder, replyUnread:boolean) {
builder.addFieldInt8(0, +replyUnread, +false);
}
static endFeedbackUnread(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createFeedbackUnread(builder:flatbuffers.Builder, replyUnread:boolean):flatbuffers.Offset {
FeedbackUnread.startFeedbackUnread(builder);
FeedbackUnread.addReplyUnread(builder, replyUnread);
return FeedbackUnread.endFeedbackUnread(builder);
}
}
+26
View File
@@ -59,6 +59,9 @@ export const app = $state<{
notifications: number;
/** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */
chatUnread: Record<string, number>;
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
* with friend requests) and the Settings → Info badge. */
feedbackReplyUnread: boolean;
/** Monotonic counter bumped when the app returns to the foreground without the live stream
* having dropped. An open game watches it to refetch once, recovering an in-game event shed
* from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */
@@ -79,6 +82,7 @@ export const app = $state<{
localeLocked: false,
notifications: 0,
chatUnread: {},
feedbackReplyUnread: false,
resync: 0,
});
@@ -257,6 +261,10 @@ function openStream(): void {
if ((e.sub === 'invitation' || e.sub === 'invitation_update') && e.invitation) {
patchLobbyInvitation(e.invitation);
}
// An operator answered the player's feedback: raise the Settings/Info badge.
if (e.sub === 'admin_reply') {
app.feedbackReplyUnread = true;
}
void refreshNotifications();
}
},
@@ -299,6 +307,24 @@ export async function refreshNotifications(): Promise<void> {
}
}
/**
* refreshFeedbackBadge recomputes whether an operator feedback reply awaits the
* player (the Settings → Info badge, folded into the lobby ⚙️ badge). Authoritative
* poll, complementing the live 'admin_reply' push. Guests cannot submit feedback, so
* it is a no-op for them.
*/
export async function refreshFeedbackBadge(): Promise<void> {
if (!app.session || app.profile?.isGuest) {
app.feedbackReplyUnread = false;
return;
}
try {
app.feedbackReplyUnread = await gateway.feedbackUnread();
} catch {
// Best-effort; leave the previous flag on a transient failure.
}
}
function closeStream(): void {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { detectChannel } from './channel';
describe('detectChannel', () => {
it('prefers telegram over everything', () => {
expect(detectChannel({ telegram: true })).toBe('telegram');
expect(detectChannel({ telegram: true, capacitorPlatform: 'ios' })).toBe('telegram');
});
it('maps the native Capacitor platforms', () => {
expect(detectChannel({ telegram: false, capacitorPlatform: 'ios' })).toBe('ios');
expect(detectChannel({ telegram: false, capacitorPlatform: 'android' })).toBe('android');
});
it('falls back to web for capacitor web, missing, or unknown platforms', () => {
expect(detectChannel({ telegram: false, capacitorPlatform: 'web' })).toBe('web');
expect(detectChannel({ telegram: false })).toBe('web');
expect(detectChannel({ telegram: false, capacitorPlatform: 'desktop' })).toBe('web');
});
});
+30
View File
@@ -0,0 +1,30 @@
// The submitting platform ("channel") reported with a feedback message. Detected
// from the runtime environment: Telegram Mini App, the Capacitor native shell
// (iOS/Android), or a plain web browser. No new dependency — the Capacitor runtime
// global is feature-detected.
import { insideTelegram } from './telegram';
export type Channel = 'telegram' | 'ios' | 'android' | 'web';
/**
* detectChannel picks the channel from the runtime signals: telegram wins, then a
* native Capacitor platform (ios/android), else web. Pure, so it is unit-tested
* without a DOM.
*/
export function detectChannel(signals: { telegram: boolean; capacitorPlatform?: string }): Channel {
if (signals.telegram) return 'telegram';
if (signals.capacitorPlatform === 'ios' || signals.capacitorPlatform === 'android') {
return signals.capacitorPlatform;
}
return 'web';
}
/** clientChannel returns the submitting platform from the current runtime. */
export function clientChannel(): Channel {
const capacitorPlatform =
typeof window === 'undefined'
? undefined
: (window as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
return detectChannel({ telegram: insideTelegram(), capacitorPlatform });
}
+9
View File
@@ -9,6 +9,7 @@ import type {
BlockStatus,
ChatMessage,
EvalResult,
FeedbackState,
FriendCode,
GameList,
GameView,
@@ -101,6 +102,14 @@ export interface GatewayClient {
chatList(gameId: string): Promise<ChatMessage[]>;
nudge(gameId: string): Promise<ChatMessage>;
// --- feedback ---
/** feedbackSubmit sends a feedback message with an optional single attachment. */
feedbackSubmit(body: string, attachment: Uint8Array | null, attachmentName: string, channel: string): Promise<void>;
/** feedbackGet returns the feedback screen state and marks any pending reply delivered. */
feedbackGet(): Promise<FeedbackState>;
/** feedbackUnread reports whether an operator reply awaits delivery (the badge). */
feedbackUnread(): Promise<boolean>;
// --- friends ---
friendsList(): Promise<AccountRef[]>;
friendsIncoming(): Promise<AccountRef[]>;
+60
View File
@@ -13,9 +13,12 @@ import {
decodeMatchResult,
decodeOutgoingList,
decodeSession,
decodeFeedbackState,
decodeFeedbackUnread,
decodeStateView,
decodeStats,
encodeCheckWord,
encodeFeedbackSubmit,
encodeDraftSave,
encodeExchange,
encodeStateRequest,
@@ -38,6 +41,63 @@ describe('codec', () => {
expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
});
it('round-trips a feedback submit and decodes state + unread', () => {
const att = new Uint8Array([1, 2, 3, 4]);
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios')),
);
expect(req.body()).toBe('please fix');
expect(req.attachmentName()).toBe('shot.png');
expect(req.channel()).toBe('ios');
expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]);
// No attachment: the vector is empty.
const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web')),
);
expect(req2.body()).toBe('hi');
expect(req2.attachmentLength()).toBe(0);
// State carrying a reply.
const b = new Builder(128);
const replyBody = b.createString('we are on it');
fb.FeedbackReply.startFeedbackReply(b);
fb.FeedbackReply.addBody(b, replyBody);
fb.FeedbackReply.addRepliedAtUnix(b, BigInt(1700000000));
const reply = fb.FeedbackReply.endFeedbackReply(b);
const reason = b.createString('');
fb.FeedbackState.startFeedbackState(b);
fb.FeedbackState.addCanSend(b, true);
fb.FeedbackState.addBlockedReason(b, reason);
fb.FeedbackState.addReply(b, reply);
b.finish(fb.FeedbackState.endFeedbackState(b));
expect(decodeFeedbackState(b.asUint8Array())).toEqual({
canSend: true,
blockedReason: '',
reply: { body: 'we are on it', repliedAtUnix: 1700000000 },
});
// State with no reply, blocked as pending.
const b2 = new Builder(64);
const reason2 = b2.createString('pending');
fb.FeedbackState.startFeedbackState(b2);
fb.FeedbackState.addCanSend(b2, false);
fb.FeedbackState.addBlockedReason(b2, reason2);
b2.finish(fb.FeedbackState.endFeedbackState(b2));
expect(decodeFeedbackState(b2.asUint8Array())).toEqual({
canSend: false,
blockedReason: 'pending',
reply: null,
});
// Unread badge flag.
const b3 = new Builder(16);
fb.FeedbackUnread.startFeedbackUnread(b3);
fb.FeedbackUnread.addReplyUnread(b3, true);
b3.finish(fb.FeedbackUnread.endFeedbackUnread(b3));
expect(decodeFeedbackUnread(b3.asUint8Array())).toBe(true);
});
it('decodes a temporary BlockStatus with reason', () => {
const b = new Builder(64);
const until = b.createString('2026-07-01T12:00:00Z');
+34
View File
@@ -12,6 +12,7 @@ import type {
BlockStatus,
ChatMessage,
EvalResult,
FeedbackState,
FriendCode,
GameList,
GameView,
@@ -437,6 +438,39 @@ export function decodeChatList(buf: Uint8Array): ChatMessage[] {
return out;
}
export function encodeFeedbackSubmit(
body: string,
attachment: Uint8Array | null,
attachmentName: string,
channel: string,
): Uint8Array {
const b = new Builder(256);
const bodyOff = b.createString(body);
const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0;
const nameOff = b.createString(attachmentName);
const chOff = b.createString(channel);
fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b);
fb.FeedbackSubmitRequest.addBody(b, bodyOff);
if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff);
fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff);
fb.FeedbackSubmitRequest.addChannel(b, chOff);
return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b));
}
export function decodeFeedbackState(buf: Uint8Array): FeedbackState {
const st = fb.FeedbackState.getRootAsFeedbackState(new ByteBuffer(buf));
const r = st.reply();
return {
canSend: st.canSend(),
blockedReason: s(st.blockedReason()),
reply: r ? { body: s(r.body()), repliedAtUnix: Number(r.repliedAtUnix()) } : null,
};
}
export function decodeFeedbackUnread(buf: Uint8Array): boolean {
return fb.FeedbackUnread.getRootAsFeedbackUnread(new ByteBuffer(buf)).replyUnread();
}
export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null {
const bb = new ByteBuffer(payload);
switch (kind) {
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { attachmentError, MAX_ATTACHMENT_BYTES } from './feedback';
describe('attachmentError', () => {
it('accepts allowed extensions within the size cap', () => {
expect(attachmentError('shot.png', 1000)).toBe('');
expect(attachmentError('Photo.JPG', 1000)).toBe(''); // case-insensitive
expect(attachmentError('report.pdf', 1000)).toBe('');
expect(attachmentError('my.notes.docx', 1000)).toBe(''); // dotted name
expect(attachmentError('logs.7z', MAX_ATTACHMENT_BYTES)).toBe('');
});
it('rejects disallowed or extensionless names', () => {
expect(attachmentError('evil.exe', 10)).toBe('type');
expect(attachmentError('x.svg', 10)).toBe('type'); // XSS vector, not allowed
expect(attachmentError('x.html', 10)).toBe('type');
expect(attachmentError('README', 10)).toBe('type');
expect(attachmentError('', 10)).toBe('type');
});
it('rejects too-large files', () => {
expect(attachmentError('shot.png', MAX_ATTACHMENT_BYTES + 1)).toBe('size');
});
});
+29
View File
@@ -0,0 +1,29 @@
// Feedback client-side limits and the attachment pre-upload gate. The gate is by
// file extension only (the UI does not list the allowed types and never uploads a
// rejected file); the server re-checks the same limits as the trust boundary.
/** MAX_BODY caps the feedback message length, in characters (matches the backend). */
export const MAX_BODY = 1024;
/** MAX_ATTACHMENT_BYTES caps a single attachment's size (matches the backend). */
export const MAX_ATTACHMENT_BYTES = 1_000_000;
// Allowed attachment extensions, mirrored from the backend allow-list.
const ALLOWED_EXT = new Set([
'png', 'jpg', 'jpeg', 'webp', 'gif', // images
'pdf', 'txt', 'log', 'doc', 'docx', 'rtf', 'zip', 'gz', '7z',
]);
/**
* attachmentError validates a picked file by name and size, returning 'type' for a
* disallowed (or missing) extension, 'size' for a too-large file, or '' when it is
* acceptable. The caller shows a single generic "cannot attach" message regardless
* of which non-empty reason it is, so the allowed types are never revealed.
*/
export function attachmentError(name: string, size: number): '' | 'type' | 'size' {
const dot = name.lastIndexOf('.');
const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
if (!ext || !ALLOWED_EXT.has(ext)) return 'type';
if (size > MAX_ATTACHMENT_BYTES) return 'size';
return '';
}
+6 -1
View File
@@ -22,8 +22,13 @@ if (isMock && typeof window !== 'undefined') {
};
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer).
(window as unknown as { __mock?: { joinOpponent(): void; joinOpponentSilently(): void } }).__mock = {
(
window as unknown as {
__mock?: { joinOpponent(): void; joinOpponentSilently(): void; adminReply(): void };
}
).__mock = {
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
adminReply: () => (gateway as MockGateway).mockAdminReply(),
};
}
+11
View File
@@ -170,6 +170,17 @@ export const en = {
'about.description': 'A multiplatform Scrabble game.',
'about.version': 'Version {v}',
'feedback.title': 'Feedback',
'feedback.placeholder': 'Describe the problem or share your suggestion…',
'feedback.attach': 'Attach file',
'feedback.removeFile': 'Remove',
'feedback.send': 'Send',
'feedback.fileRejected': 'This file cant be attached.',
'feedback.sent': 'Your message has been sent.',
'feedback.waiting': 'We are reviewing your last message.',
'feedback.banned': 'Feedback submission is unavailable.',
'feedback.replyTitle': 'Reply to your last message',
'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.',
'landing.playTelegram': 'Play in Telegram',
+11
View File
@@ -171,6 +171,17 @@ export const ru: Record<MessageKey, string> = {
'about.description': 'Мультиплатформенная игра в скрабл.',
'about.version': 'Версия {v}',
'feedback.title': 'Обратная связь',
'feedback.placeholder': 'Опишите проблему или поделитесь предложением…',
'feedback.attach': 'Прикрепить файл',
'feedback.removeFile': 'Удалить',
'feedback.send': 'Отправить',
'feedback.fileRejected': 'Этот файл нельзя прикрепить.',
'feedback.sent': 'Ваше сообщение отправлено',
'feedback.waiting': 'Ожидаем рассмотрения вашего последнего обращения',
'feedback.banned': 'Отправка обратной связи недоступна',
'feedback.replyTitle': 'Ответ на ваше последнее сообщение',
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
'landing.playTelegram': 'Играть в Telegram',
+31
View File
@@ -16,6 +16,8 @@ import type {
BlockStatus,
ChatMessage,
EvalResult,
FeedbackReply,
FeedbackState,
FriendCode,
GameList,
GcgExport,
@@ -89,6 +91,11 @@ export class MockGateway implements GatewayClient {
private readonly profile: Profile = { ...PROFILE };
private readonly subs = new Set<(e: PushEvent) => void>();
private pendingMatch: string | null = null;
// Feedback slice: a submission marks a message pending (blocks resend); mockAdminReply
// clears it and raises the badge.
private feedbackPending = false;
private feedbackReply: FeedbackReply | null = null;
private feedbackReplyUnread = false;
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
private openGameId: string | null = null;
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
@@ -409,6 +416,30 @@ export class MockGateway implements GatewayClient {
async chatList(gameId: string): Promise<ChatMessage[]> {
return [...this.game(gameId).chat];
}
async feedbackSubmit(_body: string, _attachment: Uint8Array | null, _attachmentName: string, _channel: string): Promise<void> {
this.feedbackPending = true;
}
async feedbackGet(): Promise<FeedbackState> {
this.feedbackReplyUnread = false; // fetching the screen delivers (reads) the reply
return {
canSend: !this.feedbackPending,
blockedReason: this.feedbackPending ? 'pending' : '',
reply: this.feedbackReply,
};
}
async feedbackUnread(): Promise<boolean> {
return this.feedbackReplyUnread;
}
// 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 {
this.feedbackPending = false;
this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) };
this.feedbackReplyUnread = true;
this.emit({ kind: 'notify', sub: 'admin_reply' });
}
async nudge(gameId: string): Promise<ChatMessage> {
const g = this.game(gameId);
const msg: ChatMessage = {
+17
View File
@@ -105,6 +105,23 @@ export interface ChatMessage {
createdAtUnix: number;
}
/** FeedbackReply is the operator's answer shown on the feedback screen. */
export interface FeedbackReply {
body: string;
repliedAtUnix: number;
}
/**
* FeedbackState is the feedback screen state. blockedReason is "" (can send),
* "pending" (a previous message is still under review) or "banned"; reply is the
* operator's answer to show, or null.
*/
export interface FeedbackState {
canSend: boolean;
blockedReason: string;
reply: FeedbackReply | null;
}
export interface Profile {
userId: string;
displayName: string;
+3
View File
@@ -13,6 +13,7 @@ export type RouteName =
| 'settings'
| 'about'
| 'friends'
| 'feedback'
| 'stats'
| 'notfound';
@@ -53,6 +54,8 @@ export function parse(hash: string): Route {
return { name: 'about', params: {} };
case 'friends':
return { name: 'friends', params: {} };
case 'feedback':
return { name: 'feedback', params: {} };
case 'stats':
return { name: 'stats', params: {} };
default:
+9
View File
@@ -143,6 +143,15 @@ export function createTransport(baseUrl: string): GatewayClient {
async nudge(id) {
return codec.decodeChatMessage(await exec('chat.nudge', codec.encodeGameAction(id)));
},
async feedbackSubmit(body, attachment, attachmentName, channel) {
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel));
},
async feedbackGet() {
return codec.decodeFeedbackState(await exec('feedback.get', codec.empty()));
},
async feedbackUnread() {
return codec.decodeFeedbackUnread(await exec('feedback.unread', codec.empty()));
},
async friendsList() {
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
+5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
import { aboutContent } from '../lib/aboutContent';
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
@@ -31,6 +32,10 @@
</section>
<p class="muted">{t('about.version', { v: version })}</p>
{#if !(app.profile?.isGuest ?? true)}
<button class="btn feedback" onclick={() => navigate('/feedback')}>{t('feedback.title')}</button>
{/if}
</div>
<style>
+189
View File
@@ -0,0 +1,189 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { t } from '../lib/i18n/index.svelte';
import { app, handleError } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { connection } from '../lib/connection.svelte';
import { clientChannel } from '../lib/channel';
import { attachmentError, MAX_BODY } from '../lib/feedback';
import type { FeedbackState } from '../lib/model';
// The feedback screen: a message (+ optional single attachment) the player sends to
// the operators, plus the operator's last reply. The form is disabled while a
// previous message is under review or the account is banned from feedback.
let view = $state<FeedbackState | null>(null);
let body = $state('');
let file = $state<File | null>(null);
let fileError = $state(false);
let sending = $state(false);
let sent = $state(false);
let fileInput: HTMLInputElement;
const enabled = $derived(view?.canSend ?? false);
const reason = $derived(view?.blockedReason ?? '');
const canSend = $derived(enabled && body.trim().length > 0 && !sending && connection.online);
onMount(() => void load());
async function load() {
try {
view = await gateway.feedbackGet();
// Fetching the screen delivers (reads) any pending reply: clear the badge.
app.feedbackReplyUnread = false;
} catch (e) {
handleError(e);
}
}
function pickFile(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const f = input.files?.[0] ?? null;
if (!f) return;
if (attachmentError(f.name, f.size) !== '') {
fileError = true;
file = null;
input.value = '';
return;
}
fileError = false;
file = f;
}
function removeFile() {
file = null;
fileError = false;
if (fileInput) fileInput.value = '';
}
async function send() {
if (!canSend) return;
sending = true;
try {
let bytes: Uint8Array | null = null;
let name = '';
if (file) {
bytes = new Uint8Array(await file.arrayBuffer());
name = file.name;
}
await gateway.feedbackSubmit(body.trim(), bytes, name, clientChannel());
body = '';
removeFile();
sent = true;
// The message is now under review: block resending until the operator handles it.
view = { canSend: false, blockedReason: 'pending', reply: view?.reply ?? null };
} catch (e) {
handleError(e);
} finally {
sending = false;
}
}
</script>
<Screen title={t('feedback.title')} back="/about">
<div class="page">
<textarea
bind:value={body}
maxlength={MAX_BODY}
rows="6"
placeholder={t('feedback.placeholder')}
disabled={!enabled}
></textarea>
<div class="row">
<input bind:this={fileInput} type="file" class="hidden" onchange={pickFile} />
{#if file}
<span class="fname">{file.name}</span>
<button type="button" class="ghost" onclick={removeFile}>{t('feedback.removeFile')}</button>
{:else}
<button type="button" class="ghost" onclick={() => fileInput.click()} disabled={!enabled}>
{t('feedback.attach')}
</button>
{/if}
<button type="button" class="btn" onclick={send} disabled={!canSend}>{t('feedback.send')}</button>
</div>
{#if fileError}<p class="err">{t('feedback.fileRejected')}</p>{/if}
{#if sent}
<p class="ok">{t('feedback.sent')}</p>
{:else if reason === 'pending'}
<p class="muted">{t('feedback.waiting')}</p>
{:else if reason === 'banned'}
<p class="muted">{t('feedback.banned')}</p>
{/if}
{#if view?.reply}
<section class="reply">
<h2>{t('feedback.replyTitle')}</h2>
<p class="replybody">{view.reply.body}</p>
</section>
{/if}
</div>
</Screen>
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 12px;
}
textarea {
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 7rem;
font: inherit;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fname {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-muted);
font-size: 0.9rem;
}
.row .btn {
margin-left: auto;
}
.hidden {
display: none;
}
.err {
color: var(--danger);
margin: 0;
}
.ok {
color: var(--accent);
margin: 0;
}
.muted {
color: var(--text-muted);
margin: 0;
}
.reply {
display: flex;
flex-direction: column;
gap: 6px;
border-top: 1px solid var(--surface-2);
padding-top: 10px;
}
.reply h2 {
font-size: 1.05rem;
margin: 0;
color: var(--text-muted);
}
.replybody {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
</style>
+7 -3
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import { app, handleError } from '../lib/app.svelte';
import { app, handleError, refreshFeedbackBadge } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
@@ -18,15 +18,19 @@
let incoming = $state<AccountRef[]>([]);
const guest = $derived(app.profile?.isGuest ?? true);
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
// operator feedback reply (the Settings → Info badge folds in here).
const settingsBadge = $derived(app.notifications + (app.feedbackReplyUnread ? 1 : 0));
async function load() {
try {
games = (await gateway.gamesList()).games;
if (!guest) {
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
// The ⚙️ badge counts only what lives behind it (incoming friend requests);
// The ⚙️ badge counts incoming friend requests plus an awaiting feedback reply;
// invitations surface in their own lobby section above.
app.notifications = incoming.length;
void refreshFeedbackBadge();
}
setLobby({ games, invitations, incoming });
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
@@ -234,7 +238,7 @@
<span class="sq">📊</span><span class="lbl">{t('lobby.stats')}</span>
</button>
<button class="tab" onclick={() => navigate('/settings')}>
<span class="sq">⚙️{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span>
<span class="sq">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
<span class="lbl">{t('lobby.settings')}</span>
</button>
</TabBar>
+1 -1
View File
@@ -57,7 +57,7 @@
</button>
{/if}
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
<span class="sq" aria-hidden="true"></span><span class="lbl">{t('about.tab')}</span>
<span class="sq" aria-hidden="true">{#if app.feedbackReplyUnread}<span class="badge">1</span>{/if}</span><span class="lbl">{t('about.tab')}</span>
</button>
</TabBar>
{/snippet}