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:
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user