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
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:
@@ -0,0 +1,45 @@
|
||||
import { expect, test, type Page } from './fixtures';
|
||||
|
||||
// User feedback against the mock transport (no backend). The mock profile is a
|
||||
// durable (non-guest) account, so the Settings → Info "Feedback" entry is shown.
|
||||
|
||||
async function loginLobby(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
}
|
||||
|
||||
// openFeedback navigates lobby ⚙️ → Info tab → the Feedback screen.
|
||||
async function openFeedback(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /Settings/ }).click();
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
await page.getByRole('button', { name: 'Info', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Feedback', exact: true }).click();
|
||||
}
|
||||
|
||||
test('feedback: submit a message, then resend is blocked', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await openFeedback(page);
|
||||
|
||||
await expect(page.getByPlaceholder(/Describe the problem/)).toBeVisible();
|
||||
await page.locator('textarea').fill('The board does not render on my phone.');
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
|
||||
await expect(page.getByText('Your message has been sent.')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('feedback: an operator reply raises the badge and shows on the screen', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
|
||||
// Simulate the operator answering: clears any pending message, sets the reply and
|
||||
// pushes the admin_reply live event.
|
||||
await page.evaluate(() => (window as unknown as { __mock: { adminReply(): void } }).__mock.adminReply());
|
||||
|
||||
// The lobby ⚙️ badge folds the awaiting reply into its count.
|
||||
await expect(page.getByRole('button', { name: /Settings/ }).locator('.badge')).toBeVisible();
|
||||
|
||||
await openFeedback(page);
|
||||
await expect(page.getByText('Reply to your last message')).toBeVisible();
|
||||
await expect(page.getByText(/looking into it/)).toBeVisible();
|
||||
});
|
||||
+7
-3
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,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[]>;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 can’t 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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user