feat(feedback): the contribution board in Studio and the terminal, through the kernel

This commit is contained in:
esengine committed 2026-10-18 10:31:58 -07:00
1 parent ea0538a72e
commit e6ec598a58
30 files changed
+1200 -40

No files matched your search

+4
View File
@@ -280,6 +280,10 @@ export const ACTIONS: UIAction[] = [
{ id: "feedback.account-link", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.board-join", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.board-leave", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.board-release", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.withdraw-open", kind: "view", target: "entity", proof: "interaction" },
@@ -215,4 +215,20 @@ export const EN_FEEDBACK: Record<string, string> = {
"这台电脑还没有发过反馈,发出第一条之后才能关联账号。": "This computer has not sent a report yet. You can link it to an account after the first one.",
"账号关联暂时不可用,请稍后再试。": "Account linking is unavailable right now. Please try again later.",
"操作没有完成,请稍后重试。": "That did not finish. Please try again later.",
"{n} 条被采纳": "{n} adopted",
"先在设置里登录账号,才能决定是否上榜。": "Sign in from Settings first to choose whether to be on the board.",
"全部版本": "All releases",
"加入贡献榜": "Join the board",
"名次 {n}": "Place {n}",
"名次取决于维护者选择发布的内容:被婉拒的好反馈不计入。": "A ranking depends on what the maintainers chose to ship: a good report that was declined earns nothing.",
"我在榜上": "My place on the board",
"榜上将显示为:{name}": "It will be shown on the board as: {name}",
"榜上显示为": "Shown on the board as",
"榜上显示的名字": "Name shown on the board",
"给反馈被采纳并发布的人。按版本看有名次,全部版本只按名字排序,不比高低。只有自己选择加入的人才会出现。": "For people whose feedback was adopted and shipped. A release has a ranking; all releases together are listed by name, not ranked. Only people who choose to join appear.",
"贡献榜": "Contribution board",
"这个版本还没有人上榜。": "Nobody is listed for this release yet.",
"这个账号已被维护者从榜单移除,暂时不能再加入。": "A maintainer removed this account from the board; it cannot join again for now.",
"退出榜单": "Leave the board",
"默认不上榜。加入后榜上只显示下面这个名字和采纳数,不显示邮箱、安装 ID 或路径;随时可以退出,退出后下一次刷新就不再出现。": "Not on the board by default. Once you join, the board shows only the name below and your adopted count, never an email, install id or path; you can leave at any time and you disappear from the next refresh.",
};
@@ -59,6 +59,11 @@ export const EN_KERNEL: Record<string, string> = {
"需要先登录账号": "Sign in to your account first",
"这台电脑已关联到另一个账号": "This computer is already linked to another account",
"这个账号关联的电脑已达上限": "This account has reached its limit of linked computers",
"名字需要 2 到 30 个字符:字母、数字、空格、下划线或连字符,不能含 @ 或网址": "A name is 2 to 30 letters, digits, spaces, underscores or hyphens, with no @ and no links",
"这个名字是保留的,会让人误以为是维护者,换一个吧": "That name is reserved because it could be mistaken for a maintainer; pick another",
"已经有人用这个名字了": "Someone already uses that name",
"这个账号已被维护者从榜单移除,暂时不能再加入": "A maintainer removed this account from the board; it cannot join again for now",
"先把这台电脑关联到账号,才能加入榜单": "Link this computer to your account first, then join the board",
"这台电脑还没有发过反馈": "This computer has not sent a report yet",
"截图无法清除其中的元数据,请换一张或先用截图工具重新导出": "An image could not be cleaned of its metadata - pick another or re-export it",
"反馈内容不符合要求,请检查后重试": "That feedback could not be accepted - check the fields and try again",
+5
View File
@@ -699,6 +699,11 @@ const SAID: Record<string, string> = {
"feedback.link_conflict": "这台电脑已关联到另一个账号",
"feedback.link_limit": "这个账号关联的电脑已达上限",
"feedback.no_install": "这台电脑还没有发过反馈",
"feedback.board_name_invalid": "名字需要 2 到 30 个字符:字母、数字、空格、下划线或连字符,不能含 @ 或网址",
"feedback.board_name_reserved": "这个名字是保留的,会让人误以为是维护者,换一个吧",
"feedback.board_name_taken": "已经有人用这个名字了",
"feedback.board_delisted": "这个账号已被维护者从榜单移除,暂时不能再加入",
"feedback.board_no_link": "先把这台电脑关联到账号,才能加入榜单",
// ── 远程连接停下来问的那一句 ───────────────────────────────────
"ask.not_found": "不存在该待回答的问题",
@@ -200,6 +200,37 @@ export const FEEDBACK_LIMIT = {
globalBurst: "global_burst",
} as const;
// One person on the contribution board: the name they chose, how many of their
// suggestions shipped, where, and (in a release view only) their place.
export interface FeedbackBoardEntry {
displayName: string;
credits: number;
rank: number | null;
releases: FeedbackBoardRelease[];
}
export interface FeedbackBoardRelease {
version: string;
count: number;
}
// The public board. available is false where the service does not serve one, which
// hides the section rather than failing it.
export interface FeedbackBoard {
available: boolean;
view: "all" | "release";
release: string | null;
entries: FeedbackBoardEntry[];
}
// What the signed-in person has chosen about being on the board.
export interface FeedbackBoardMe {
listed: boolean;
displayName: string | null;
// A maintainer removed this account; it cannot list itself again until restored.
delisted: boolean;
}
export const FEEDBACK_REPO_ISSUES = "https://github.com/esengine/DeepSeek-Reasonix/issues/";
export const FEEDBACK_NEXT_VERSION = "next";
@@ -1,6 +1,6 @@
import { HttpError } from "./http_error";
import { MockCommit } from "./mock_commit";
import { FEEDBACK_CODE, canWithdraw, type FeedbackEnv, type FeedbackImage, type FeedbackItem, type FeedbackLinkedInstall, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
import { FEEDBACK_CODE, canWithdraw, type FeedbackBoard, type FeedbackBoardEntry, type FeedbackBoardMe, type FeedbackEnv, type FeedbackImage, type FeedbackItem, type FeedbackLinkedInstall, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, imageBytes: 2 << 20, uploadBytes: 10 << 20, replyBytes: 4096 };
@@ -194,6 +194,55 @@ export class MockFeedback extends MockCommit {
this.links = this.links.filter((l) => !l.current);
}
// The demo board: a handful of people, credits spread over three releases.
private board: FeedbackBoardEntry[] = [
{ displayName: "Sample Alpha", credits: 7, rank: null, releases: [{ version: "v2.45.0", count: 3 }, { version: "v2.44.0", count: 2 }, { version: "v2.43.0", count: 2 }] },
{ displayName: "示例用户", credits: 5, rank: null, releases: [{ version: "v2.45.0", count: 2 }, { version: "v2.44.0", count: 3 }] },
{ displayName: "sample_beta", credits: 3, rank: null, releases: [{ version: "v2.45.0", count: 2 }, { version: "v2.43.0", count: 1 }] },
{ displayName: "Sample-Gamma", credits: 2, rank: null, releases: [{ version: "v2.44.0", count: 1 }, { version: "v2.43.0", count: 1 }] },
{ displayName: "Sample Delta", credits: 1, rank: null, releases: [{ version: "v2.45.0", count: 1 }] },
];
private chosen: string | null = null;
private boardMe(): FeedbackBoardMe {
return { listed: this.chosen !== null, displayName: this.chosen, delisted: false };
}
async feedbackBoard(release?: string): Promise<FeedbackBoard> {
if (!release) {
const all = [...this.board].sort((a, b) => a.displayName.localeCompare(b.displayName));
return { available: true, view: "all", release: null, entries: all };
}
const rows = this.board
.map((e) => ({ ...e, releases: e.releases.filter((r) => r.version === release) }))
.filter((e) => e.releases.length > 0)
.map((e) => ({ ...e, credits: e.releases.reduce((n, r) => n + r.count, 0) }))
.sort((a, b) => b.credits - a.credits || a.displayName.localeCompare(b.displayName));
let rank = 0;
const entries = rows.map((e, i) => {
if (i === 0 || e.credits !== rows[i - 1].credits) rank = i + 1;
return { ...e, rank };
});
return { available: true, view: "release", release, entries };
}
async feedbackBoardMe(): Promise<FeedbackBoardMe> {
this.requireAccount();
return this.boardMe();
}
async joinFeedbackBoard(displayName: string): Promise<FeedbackBoardMe> {
this.requireAccount();
this.chosen = displayName.trim();
return this.boardMe();
}
async leaveFeedbackBoard(): Promise<FeedbackBoardMe> {
this.requireAccount();
this.chosen = null;
return this.boardMe();
}
async unlinkFeedbackInstall(id: string): Promise<void> {
this.requireAccount();
this.links = this.links.filter((l) => l.id !== id);
+7 -1
View File
@@ -51,7 +51,7 @@ import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, Ses
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
import type { FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
import type { FeedbackBoard, FeedbackBoardMe, FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
import type { PersonaDraft, PersonaSettings } from "./boundary";
export type * from "./plugin";
@@ -495,6 +495,12 @@ export interface AgentPort {
linkFeedbackAccount(): Promise<void>;
unlinkFeedbackAccount(): Promise<void>;
unlinkFeedbackInstall(id: string): Promise<void>;
// The public contribution board, all releases or one; a person is on it only by
// their own choice, under a name they pick, and can leave at any time.
feedbackBoard(release?: string): Promise<FeedbackBoard>;
feedbackBoardMe(): Promise<FeedbackBoardMe>;
joinFeedbackBoard(displayName: string): Promise<FeedbackBoardMe>;
leaveFeedbackBoard(): Promise<FeedbackBoardMe>;
// Asks the window to raise the neutral "new reply" notification; the kernel
// answers for the switches. Rejects where there is no window to raise it.
announceFeedbackReply(): Promise<void>;
+15 -1
View File
@@ -1,5 +1,5 @@
import { SseBackup } from "./sse_backup";
import type { FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
import type { FeedbackBoard, FeedbackBoardMe, FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
export class SseFeedback extends SseBackup {
feedbackEnv(locale?: string) {
@@ -35,6 +35,20 @@ export class SseFeedback extends SseBackup {
unlinkFeedbackAccount() {
return this.del("/feedback/link");
}
feedbackBoard(release?: string) {
return this.get<FeedbackBoard>("/feedback/board" + (release ? "?release=" + encodeURIComponent(release) : ""));
}
feedbackBoardMe() {
return this.get<FeedbackBoardMe>("/feedback/board/me");
}
async joinFeedbackBoard(displayName: string) {
await this.post("/feedback/board/join", { displayName });
return this.feedbackBoardMe();
}
async leaveFeedbackBoard() {
await this.post("/feedback/board/leave", {});
return this.feedbackBoardMe();
}
unlinkFeedbackInstall(id: string) {
return this.del("/feedback/links/" + encodeURIComponent(id));
}
@@ -292,3 +292,18 @@
.fbk-withdraw-acts { display: flex; gap: 8px; margin-top: 6px; }
.fbk-withdraw .btn.danger { border-color: color-mix(in srgb, var(--err) 42%, transparent); color: var(--err-ink); }
.fbk-withdraw .btn.danger:hover:not(:disabled) { background: color-mix(in srgb, var(--err) 10%, transparent); }
.fbk-board { display: flex; flex-direction: column; gap: 10px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--raised); }
.fbk-board-pick { display: flex; align-items: center; gap: 8px; }
.fbk-board-pick select { min-width: 140px; }
.fbk-board-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.fbk-board-list li { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 6px 0; border-top: 1px solid var(--hair); }
.fbk-board-list li:first-child { border-top: 0; }
.fbk-board-rank { flex: none; width: 22px; text-align: right; font: var(--w-semi) var(--fs-ui) var(--mono); color: var(--muted); }
.fbk-board-list[data-ranked] li:nth-child(-n+3) .fbk-board-rank { color: var(--brand); }
.fbk-board-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui) var(--ui); color: var(--text); }
.fbk-board-credits { flex: none; color: var(--muted); font-size: var(--fs-meta); }
.fbk-board-where { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
.fbk-board-me { display: flex; flex-direction: column; gap: 8px; padding-top: 10px; border-top: 1px solid var(--hair); }
.fbk-board-name-field { flex: 1 1 180px; min-width: 0; }
.fbk-board .fbk-hint[data-tone="error"] { color: var(--err-ink); }
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { FeedbackBoard } from "./FeedbackBoard";
import { MockPort } from "../port/mock";
import { HttpError, type AgentPort } from "../port/port";
import { boot, STORAGE } from "../i18n";
afterEach(() => {
cleanup();
localStorage.removeItem("reasonix.mock.account");
localStorage.setItem(STORAGE, "zh");
boot();
});
const signIn = () => localStorage.setItem("reasonix.mock.account", "1");
function spied() {
const port = new MockPort() as unknown as AgentPort;
port.feedbackBoard = vi.fn(port.feedbackBoard.bind(port));
port.feedbackBoardMe = vi.fn(port.feedbackBoardMe.bind(port));
port.joinFeedbackBoard = vi.fn(port.joinFeedbackBoard.bind(port));
port.leaveFeedbackBoard = vi.fn(port.leaveFeedbackBoard.bind(port));
return port;
}
describe("FeedbackBoard", () => {
it("is not drawn at all where the service serves no board", async () => {
const port = spied();
port.feedbackBoard = vi.fn(async () => ({ available: false, view: "all" as const, release: null, entries: [] }));
const { container } = render(<FeedbackBoard port={port} />);
await waitFor(() => expect(port.feedbackBoard).toHaveBeenCalled());
expect(container.querySelector(".fbk-board")).toBeNull();
});
it("lists all releases by name with no places, and a release with places where ties share one", async () => {
const port = spied();
render(<FeedbackBoard port={port} />);
await screen.findByText("Sample Alpha");
expect(document.querySelectorAll(".fbk-board-rank")).toHaveLength(0);
await userEvent.selectOptions(screen.getByRole("combobox", { name: "版本" }), "v2.45.0");
await waitFor(() => expect(document.querySelectorAll(".fbk-board-rank").length).toBeGreaterThan(0));
const places = [...document.querySelectorAll(".fbk-board-rank")].map((n) => n.textContent);
expect(places).toEqual(["1", "2", "2", "4"]);
expect(port.feedbackBoard).toHaveBeenLastCalledWith("v2.45.0");
});
it("does not ask the account anything while signed out, and says to sign in", async () => {
const port = spied();
render(<FeedbackBoard port={port} />);
expect(await screen.findByText("先在设置里登录账号,才能决定是否上榜。")).toBeTruthy();
expect(port.feedbackBoardMe).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "加入贡献榜" })).toBeNull();
});
it("previews the exact name, joins only when pressed, and can leave again", async () => {
signIn();
const port = spied();
render(<FeedbackBoard port={port} />);
const field = await screen.findByRole("textbox", { name: "榜上显示的名字" });
const join = screen.getByRole("button", { name: "加入贡献榜" }) as HTMLButtonElement;
expect(join.disabled).toBe(true);
await userEvent.type(field, " Sample Person ");
expect(screen.getByText("榜上将显示为:Sample Person")).toBeTruthy();
expect(port.joinFeedbackBoard).not.toHaveBeenCalled();
await userEvent.click(join);
await waitFor(() => expect(port.joinFeedbackBoard).toHaveBeenCalledExactlyOnceWith("Sample Person"));
await userEvent.click(await screen.findByRole("button", { name: "退出榜单" }));
await waitFor(() => expect(port.leaveFeedbackBoard).toHaveBeenCalledTimes(1));
expect(await screen.findByRole("textbox", { name: "榜上显示的名字" })).toBeTruthy();
});
it("says why a name was refused, from the refusal's code, and keeps the field", async () => {
signIn();
const port = spied();
port.joinFeedbackBoard = vi.fn(async () => { throw new HttpError(400, "x", { code: "feedback.board_name_reserved" }); });
render(<FeedbackBoard port={port} />);
await userEvent.type(await screen.findByRole("textbox", { name: "榜上显示的名字" }), "Reasonix");
await userEvent.click(screen.getByRole("button", { name: "加入贡献榜" }));
expect((await screen.findByRole("alert")).textContent).toContain("保留");
expect((screen.getByRole("textbox", { name: "榜上显示的名字" }) as HTMLInputElement).value).toBe("Reasonix");
});
it("says a maintainer removed the account instead of offering the join again", async () => {
signIn();
const port = spied();
port.feedbackBoardMe = vi.fn(async () => ({ listed: false, displayName: null, delisted: true }));
render(<FeedbackBoard port={port} />);
expect(await screen.findByText("这个账号已被维护者从榜单移除,暂时不能再加入。")).toBeTruthy();
expect(screen.queryByRole("button", { name: "加入贡献榜" })).toBeNull();
});
});
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { FeedbackBoard as Board, FeedbackBoardMe } from "../port/feedback";
import type { AgentPort } from "../port/port";
const newest = (a: string, b: string): number => {
const pa = a.replace(/^v/, "").split(".").map(Number);
const pb = b.replace(/^v/, "").split(".").map(Number);
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0);
return 0;
};
const failure = (e: unknown): string => reason(e);
// The public board of people whose suggestions shipped, and the person's own switch
// for being on it. Hidden where the service serves no board. The name is the only
// thing shown, and being listed is always the person's own, separate choice.
export function FeedbackBoard({ port }: { port: AgentPort }) {
const [all, setAll] = useState<Board | null>(null);
const [board, setBoard] = useState<Board | null>(null);
const [release, setRelease] = useState("");
const [signedIn, setSignedIn] = useState(false);
const [me, setMe] = useState<FeedbackBoardMe | null>(null);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState("");
const load = useCallback(async () => {
try {
const first = await port.feedbackBoard();
setAll(first);
setBoard(first);
const account = await port.account().catch(() => null);
const on = !!account && account.signedIn && !account.expired;
setSignedIn(on);
if (on) setMe(await port.feedbackBoardMe().catch(() => null));
} catch {
setAll(null);
}
}, [port]);
useEffect(() => { void load(); }, [load]);
const versions = useMemo(
() => [...new Set((all?.entries ?? []).flatMap((e) => e.releases.map((r) => r.version)))].sort(newest),
[all],
);
const pick = async (next: string) => {
setRelease(next);
try {
setBoard(await port.feedbackBoard(next || undefined));
} catch (e) {
setProblem(failure(e));
}
};
const run = async (act: () => Promise<FeedbackBoardMe>) => {
setBusy(true);
setProblem("");
try {
setMe(await act());
setAll(await port.feedbackBoard());
setBoard(await port.feedbackBoard(release || undefined));
} catch (e) {
setProblem(failure(e));
} finally {
setBusy(false);
}
};
if (!all?.available || !board) return null;
const ranked = board.view === "release";
const typed = draft.trim();
return (
<section className="fbk-board" aria-label={t("贡献榜")}>
<div className="fbk-link-hd">
<span className="fbk-link-name">{t("贡献榜")}</span>
<span className="fbk-hint">{t("给反馈被采纳并发布的人。按版本看有名次,全部版本只按名字排序,不比高低。只有自己选择加入的人才会出现。")}</span>
</div>
<label className="fbk-board-pick">
<span className="fbk-hint">{t("版本")}</span>
<select aria-label={t("版本")} data-action-change="feedback.board-release" value={release} onChange={(e) => void pick(e.target.value)}>
<option value="">{t("全部版本")}</option>
{versions.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</label>
{board.entries.length === 0 ? (
<p className="fbk-hint">{t("这个版本还没有人上榜。")}</p>
) : (
<ol className="fbk-board-list" data-ranked={ranked ? "" : undefined}>
{board.entries.map((e) => (
<li key={e.displayName}>
{ranked && <span className="fbk-board-rank" aria-label={t("名次 {n}", { n: e.rank ?? 0 })}>{e.rank}</span>}
<span className="fbk-board-name">{e.displayName}</span>
<span className="fbk-board-credits">{t("{n} 条被采纳", { n: e.credits })}</span>
{!ranked && (
<span className="fbk-board-where">
{e.releases.slice(0, 3).map((r) => <span key={r.version} className="fbk-chip" data-status="recorded">{r.version}{r.count > 1 ? ` ×${r.count}` : ""}</span>)}
{e.releases.length > 3 && <span className="fbk-meta">+{e.releases.length - 3}</span>}
</span>
)}
</li>
))}
</ol>
)}
<p className="fbk-hint">{t("名次取决于维护者选择发布的内容:被婉拒的好反馈不计入。")}</p>
<div className="fbk-board-me">
<span className="fbk-link-name">{t("我在榜上")}</span>
{!signedIn ? (
<p className="fbk-hint">{t("先在设置里登录账号,才能决定是否上榜。")}</p>
) : me?.delisted ? (
<p className="fbk-hint" data-tone="warn">{t("这个账号已被维护者从榜单移除,暂时不能再加入。")}</p>
) : me?.listed ? (
<div className="fbk-link-row">
<span className="fbk-chip" data-status="recorded">{t("榜上显示为")} · {me.displayName}</span>
<button type="button" className="btn sm" data-action="feedback.board-leave" disabled={busy} onClick={() => void run(() => port.leaveFeedbackBoard())}>{t("退出榜单")}</button>
</div>
) : (
<>
<p className="fbk-hint">{t("默认不上榜。加入后榜上只显示下面这个名字和采纳数,不显示邮箱、安装 ID 或路径;随时可以退出,退出后下一次刷新就不再出现。")}</p>
<div className="fbk-link-row">
<input className="fbk-board-name-field" data-action-change="feedback.board-name" aria-label={t("榜上显示的名字")} value={draft} maxLength={30} placeholder={t("榜上显示的名字")} onChange={(e) => setDraft(e.target.value)} />
<button type="button" className="btn sm" data-primary data-action="feedback.board-join" disabled={busy || typed.length < 2} onClick={() => void run(() => port.joinFeedbackBoard(typed))}>{t("加入贡献榜")}</button>
</div>
{typed.length >= 2 && <p className="fbk-hint" aria-live="polite">{t("榜上将显示为:{name}", { name: typed })}</p>}
</>
)}
</div>
{problem && <p className="fbk-hint" role="alert" data-tone="error">{problem}</p>}
</section>
);
}
@@ -8,6 +8,7 @@ import { FeedbackFilter } from "./FeedbackFilter";
import { inGroup, type FeedbackGroup } from "./feedbackgroups";
import { FeedbackLevel } from "./FeedbackLevel";
import { FeedbackLink } from "./FeedbackLink";
import { FeedbackBoard } from "./FeedbackBoard";
import { feedbackFailure, type FeedbackFailure } from "./feedbackfailure";
import { FeedbackWithdraw } from "./FeedbackWithdraw";
import { FeedbackReplyBox, FeedbackReviewNote, FeedbackThread, foldedUnread } from "./FeedbackThread";
@@ -223,6 +224,7 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
)}
{mine && <FeedbackLink port={port} hasReport={mine.items.length > 0} />}
{mine && <FeedbackBoard port={port} />}
{mine?.offline && (
<div className="fbk-note" role="status" data-tone="info">
+82 -14
View File
@@ -17,20 +17,37 @@ type FeedbackText struct {
RefusedLaterFmt string // limit name
RefusedPermanentFmt string // limit name
LinkExplain string
LinkDone string
UnlinkExplain string
UnlinkDone string
LinksHeader string
LinksNone string
LinksRowFmt string // short id, this-device mark, linked date
ThisDevice string
NeedSignIn string
LinkConflict string
LinkLimit string
NoInstall string
OtherDeviceMark string
LevelUpFmt string // level, name, previous level
LinkExplain string
LinkDone string
UnlinkExplain string
UnlinkDone string
LinksHeader string
LinksNone string
LinksRowFmt string // short id, this-device mark, linked date
ThisDevice string
NeedSignIn string
LinkConflict string
LinkLimit string
NoInstall string
OtherDeviceMark string
LevelUpFmt string // level, name, previous level
BoardHeaderFmt string // view label
BoardRowFmt string // place (or blank), name, credits
BoardRowReleasesFmt string // the releases a person shipped in
BoardNone string
BoardOff string
BoardAllView string
BoardReleaseFmt string // release
BoardJoinExplainFmt string // the name that would be shown
BoardJoinDone string
BoardLeaveDone string
BoardMeListedFmt string // name
BoardMeNot string
BoardNameInvalid string
BoardNameReserved string
BoardNameTaken string
BoardDelisted string
BoardNoLink string
}
var feedbackEnglish = FeedbackText{
@@ -70,6 +87,23 @@ var feedbackEnglish = FeedbackText{
NoInstall: "Send a report from this device first: a device with no report has nothing to link.",
OtherDeviceMark: " [other device]",
LevelUpFmt: "Level up: level %d, %s — up from %d.",
BoardHeaderFmt: "Contribution board, %s:",
BoardRowFmt: " %s %s - %d adopted%s",
BoardRowReleasesFmt: " (%s)",
BoardNone: "Nobody is listed here yet.",
BoardOff: "The service has no contribution board.",
BoardAllView: "all releases, by name",
BoardReleaseFmt: "release %s, ranked",
BoardJoinExplainFmt: "Joining shows the name \"%s\" on the public contribution board with how many of your suggestions shipped, and nothing else. You can leave at any time. Nothing was sent: run the same line again with --yes.",
BoardJoinDone: "You are on the board.",
BoardLeaveDone: "You are off the board.",
BoardMeListedFmt: "You are listed as \"%s\". Leave with /feedback board leave.",
BoardMeNot: "You are not on the board. Join with /feedback board join <name>.",
BoardNameInvalid: "That name is not allowed: use 2 to 30 letters, digits, spaces, underscores or hyphens; no @ and no links.",
BoardNameReserved: "That name is reserved because it could be mistaken for a maintainer; pick another.",
BoardNameTaken: "Someone already uses that name.",
BoardDelisted: "A maintainer removed this account from the board; it cannot join again for now.",
BoardNoLink: "Link this device to your account first: /feedback link.",
}
var feedbackChinese = FeedbackText{
@@ -109,6 +143,23 @@ var feedbackChinese = FeedbackText{
NoInstall: "请先从这台设备发出一条反馈:没有发过反馈的设备没有可关联的内容。",
OtherDeviceMark: " [其他设备]",
LevelUpFmt: "等级提升:现在是等级 %d「%s」(原为 %d)。",
BoardHeaderFmt: "贡献榜,%s:",
BoardRowFmt: " %s %s - %d 条被采纳%s",
BoardRowReleasesFmt: "(%s)",
BoardNone: "这里还没有人上榜。",
BoardOff: "服务端没有贡献榜。",
BoardAllView: "全部版本,按名字排序",
BoardReleaseFmt: "版本 %s,有名次",
BoardJoinExplainFmt: "加入后,公开的贡献榜上会显示名字「%s」和你被采纳的条数,不显示别的。随时可以退出。还没有发送:用同样的内容再加 --yes 运行一次。",
BoardJoinDone: "已加入贡献榜。",
BoardLeaveDone: "已退出贡献榜。",
BoardMeListedFmt: "你在榜上,名字是「%s」。用 /feedback board leave 退出。",
BoardMeNot: "你不在榜上。用 /feedback board join <名字> 加入。",
BoardNameInvalid: "这个名字不能用:2 到 30 个字符,字母、数字、空格、下划线或连字符;不能含 @ 或链接。",
BoardNameReserved: "这个名字是保留的,会让人误以为是维护者,换一个。",
BoardNameTaken: "已经有人用这个名字了。",
BoardDelisted: "维护者已把这个账号从榜单移除,暂时不能再加入。",
BoardNoLink: "请先把这台设备关联到账号:/feedback link。",
}
var feedbackTraditional = FeedbackText{
@@ -148,4 +199,21 @@ var feedbackTraditional = FeedbackText{
NoInstall: "請先從這台裝置送出一則回饋:沒有送過回饋的裝置沒有可關聯的內容。",
OtherDeviceMark: " [其他裝置]",
LevelUpFmt: "等級提升:現在是等級 %d「%s」(原為 %d)。",
BoardHeaderFmt: "貢獻榜,%s:",
BoardRowFmt: " %s %s - %d 條被採納%s",
BoardRowReleasesFmt: "(%s)",
BoardNone: "這裡還沒有人上榜。",
BoardOff: "服務端沒有貢獻榜。",
BoardAllView: "全部版本,按名字排序",
BoardReleaseFmt: "版本 %s,有名次",
BoardJoinExplainFmt: "加入後,公開的貢獻榜上會顯示名字「%s」和你被採納的條數,不顯示別的。隨時可以退出。還沒有送出:用同樣的內容再加 --yes 執行一次。",
BoardJoinDone: "已加入貢獻榜。",
BoardLeaveDone: "已退出貢獻榜。",
BoardMeListedFmt: "你在榜上,名字是「%s」。用 /feedback board leave 退出。",
BoardMeNot: "你不在榜上。用 /feedback board join <名字> 加入。",
BoardNameInvalid: "這個名字不能用:2 到 30 個字元,字母、數字、空格、底線或連字號;不能含 @ 或連結。",
BoardNameReserved: "這個名字是保留的,會讓人誤以為是維護者,換一個。",
BoardNameTaken: "已經有人用這個名字了。",
BoardDelisted: "維護者已把這個帳號從榜單移除,暫時不能再加入。",
BoardNoLink: "請先把這台裝置關聯到帳號:/feedback link。",
}
+3
View File
@@ -42,6 +42,7 @@ var slashHintEN = SlashHintMessages{
ArgFeedbackLink: "show your reports on every linked device",
ArgFeedbackUnlink: "stop linking this device",
ArgFeedbackLinks: "list the devices linked to your account",
ArgFeedbackBoard: "show the contribution board, or join or leave it",
ArgFeedbackName: "set the nickname reports go out under",
ArgMemoryRecall: "show the latest automatic recall decision",
ArgMemoryRevisions: "show revision history",
@@ -126,6 +127,7 @@ var slashHintZH = SlashHintMessages{
ArgFeedbackLink: "在所有已关联设备上显示你的反馈",
ArgFeedbackUnlink: "停止关联这台设备",
ArgFeedbackLinks: "列出已关联到你账号的设备",
ArgFeedbackBoard: "查看贡献榜,或加入、退出",
ArgFeedbackName: "设置反馈所用的昵称",
ArgMemoryRecall: "查看最近一次自动召回的决定",
ArgMemoryRevisions: "查看修订历史",
@@ -210,6 +212,7 @@ var slashHintZHTW = SlashHintMessages{
ArgFeedbackLink: "在所有已關聯裝置上顯示你的回饋",
ArgFeedbackUnlink: "停止關聯這台裝置",
ArgFeedbackLinks: "列出已關聯到你帳號的裝置",
ArgFeedbackBoard: "查看貢獻榜,或加入、退出",
ArgFeedbackName: "設定回饋所用的暱稱",
ArgMemoryRecall: "查看最近一次自動召回的決定",
ArgMemoryRevisions: "查看修訂歷史",
@@ -42,6 +42,7 @@ type SlashHintMessages struct {
ArgFeedbackLink string
ArgFeedbackUnlink string
ArgFeedbackLinks string
ArgFeedbackBoard string
ArgFeedbackName string
ArgMemoryRecall string
ArgMemoryRevisions string
+19
View File
@@ -37,6 +37,11 @@ const (
codeFeedbackLinkConflict = "feedback.link_conflict"
codeFeedbackLinkLimit = "feedback.link_limit"
codeFeedbackNoInstall = "feedback.no_install"
codeBoardNameInvalid = "feedback.board_name_invalid"
codeBoardNameReserved = "feedback.board_name_reserved"
codeBoardNameTaken = "feedback.board_name_taken"
codeBoardDelisted = "feedback.board_delisted"
codeBoardNoLink = "feedback.board_no_link"
)
// feedbackSurface names where a report from a hub or pane of surface from
@@ -65,6 +70,10 @@ func (s *Server) registerFeedbackRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /feedback/link", s.feedbackLink)
mux.HandleFunc("DELETE /feedback/link", s.feedbackUnlink)
mux.HandleFunc("DELETE /feedback/links/{id}", s.feedbackUnlinkInstall)
mux.HandleFunc("GET /feedback/board", s.feedbackBoard)
mux.HandleFunc("GET /feedback/board/me", s.feedbackBoardMe)
mux.HandleFunc("POST /feedback/board/join", s.feedbackBoardJoin)
mux.HandleFunc("POST /feedback/board/leave", s.feedbackBoardLeave)
}
func (s *Server) feedbackEnv(w http.ResponseWriter, r *http.Request) {
@@ -245,6 +254,16 @@ func refuseFeedback(w http.ResponseWriter, err error) {
refuse(w, http.StatusConflict, codeFeedbackLinkLimit, err.Error(), nil)
case errors.Is(err, feedback.ErrNoInstall):
refuse(w, http.StatusConflict, codeFeedbackNoInstall, err.Error(), nil)
case errors.Is(err, feedback.ErrBoardNameInvalid):
refuse(w, http.StatusBadRequest, codeBoardNameInvalid, err.Error(), nil)
case errors.Is(err, feedback.ErrBoardNameReserved):
refuse(w, http.StatusBadRequest, codeBoardNameReserved, err.Error(), nil)
case errors.Is(err, feedback.ErrBoardNameTaken):
refuse(w, http.StatusConflict, codeBoardNameTaken, err.Error(), nil)
case errors.Is(err, feedback.ErrBoardDelisted):
refuse(w, http.StatusForbidden, codeBoardDelisted, err.Error(), nil)
case errors.Is(err, feedback.ErrBoardNoLink):
refuse(w, http.StatusConflict, codeBoardNoLink, err.Error(), nil)
case errors.Is(err, feedback.ErrImageMetadata):
refuse(w, http.StatusBadRequest, codeFeedbackImageMeta, err.Error(), nil)
case errors.Is(err, feedback.ErrOffline):
+62
View File
@@ -0,0 +1,62 @@
package serve
import (
"net/http"
)
// feedbackBoard is the public contribution board, all releases or one. It needs
// no account, so a paired device may read it; what it answers when the service
// has none is available:false, not an error.
func (s *Server) feedbackBoard(w http.ResponseWriter, r *http.Request) {
got, err := s.ctl().FeedbackBoard(r.Context(), r.URL.Query().Get("release"))
if err != nil {
refuseFeedback(w, err)
return
}
writeJSON(w, got)
}
// feedbackBoardMe, feedbackBoardJoin and feedbackBoardLeave act for the account
// the window is signed in as, so a paired device cannot use them (the one list is
// hostOnlyRoutes) and each spends the account's assertion on its one call.
func (s *Server) feedbackBoardMe(w http.ResponseWriter, r *http.Request) {
if refuseDevice(w, r) {
return
}
got, err := s.ctl().FeedbackBoardMe(r.Context())
if err != nil {
refuseFeedback(w, err)
return
}
writeJSON(w, got)
}
func (s *Server) feedbackBoardJoin(w http.ResponseWriter, r *http.Request) {
if refuseDevice(w, r) {
return
}
var req struct {
DisplayName string `json:"displayName"`
}
if !decodeBody(w, r, &req) {
return
}
got, err := s.ctl().JoinFeedbackBoard(r.Context(), req.DisplayName)
if err != nil {
refuseFeedback(w, err)
return
}
writeJSON(w, got)
}
func (s *Server) feedbackBoardLeave(w http.ResponseWriter, r *http.Request) {
if refuseDevice(w, r) {
return
}
got, err := s.ctl().LeaveFeedbackBoard(r.Context())
if err != nil {
refuseFeedback(w, err)
return
}
writeJSON(w, got)
}
@@ -0,0 +1,120 @@
package serve
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/platform/feedback"
"reasonix/internal/session/control"
)
// boardCalls counts what reached the controller: a refused request must reach nothing.
type boardCalls struct {
control.SessionAPI
read, me, join, leave int
joined string
err error
}
func (b *boardCalls) FeedbackBoard(_ context.Context, _ string) (feedback.Board, error) {
b.read++
return feedback.Board{Available: true, View: "all", Entries: []feedback.BoardEntry{}}, nil
}
func (b *boardCalls) FeedbackBoardMe(_ context.Context) (feedback.BoardMe, error) {
b.me++
return feedback.BoardMe{}, b.err
}
func (b *boardCalls) JoinFeedbackBoard(_ context.Context, name string) (feedback.BoardMe, error) {
b.join++
b.joined = name
return feedback.BoardMe{Listed: b.err == nil}, b.err
}
func (b *boardCalls) LeaveFeedbackBoard(_ context.Context) (feedback.BoardMe, error) {
b.leave++
return feedback.BoardMe{}, nil
}
func boardRoute(b *boardCalls, method, path, body string, device bool) *httptest.ResponseRecorder {
mux := http.NewServeMux()
(&Server{ctrl: b}).registerFeedbackRoutes(mux)
req := httptest.NewRequest(method, path, strings.NewReader(body))
if device {
req = req.WithContext(withDeviceReach(req.Context(), "phone-1", 1))
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func TestAPairedDeviceCannotJoinLeaveOrReadTheWindowsBoardChoiceButCanReadTheBoard(t *testing.T) {
routes := []struct {
name, method, path, body string
count func(*boardCalls) int
}{
{"read the account's choice", http.MethodGet, "/feedback/board/me", "", func(b *boardCalls) int { return b.me }},
{"join", http.MethodPost, "/feedback/board/join", `{"displayName":"Sample"}`, func(b *boardCalls) int { return b.join }},
{"leave", http.MethodPost, "/feedback/board/leave", "", func(b *boardCalls) int { return b.leave }},
}
for _, rt := range routes {
device := &boardCalls{}
rec := boardRoute(device, rt.method, rt.path, rt.body, true)
var reason Reason
_ = json.Unmarshal(rec.Body.Bytes(), &reason)
if rec.Code != http.StatusNotFound || reason.Code != codeDeviceHostOnly || rt.count(device) != 0 {
t.Errorf("%s from a device: status %d code %q reached the controller %d times, want 404 %s and never", rt.name, rec.Code, reason.Code, rt.count(device), codeDeviceHostOnly)
}
host := &boardCalls{}
rec = boardRoute(host, rt.method, rt.path, rt.body, false)
if rec.Code == http.StatusNotFound || rt.count(host) != 1 {
t.Errorf("%s from the host: status %d, reached the controller %d times, want it served once", rt.name, rec.Code, rt.count(host))
}
}
device := &boardCalls{}
if rec := boardRoute(device, http.MethodGet, "/feedback/board?release=v2.1.0", "", true); rec.Code != http.StatusOK || device.read != 1 {
t.Errorf("a device reading the public board: status %d, reads %d, want it served", rec.Code, device.read)
}
}
func TestTheBoardRoutesThatActForTheAccountAreInTheOneHostOnlyTable(t *testing.T) {
for _, r := range []hostRoute{{http.MethodGet, "/feedback/board/me"}, {http.MethodPost, "/feedback/board/join"}, {http.MethodPost, "/feedback/board/leave"}} {
if !hostOnlyRoute(r.method, r.path) {
t.Errorf("%s %s is not in hostOnlyRoutes, so the remote-pane proxy would forward it with the host's token", r.method, r.path)
}
}
if hostOnlyRoute(http.MethodGet, "/feedback/board") {
t.Error("the public board read must stay open to a paired device")
}
}
func TestEachBoardRefusalAnswersItsOwnCodeAndTheNamePassesThroughUntouched(t *testing.T) {
for want, err := range map[string]error{
codeBoardNameInvalid: feedback.ErrBoardNameInvalid,
codeBoardNameReserved: feedback.ErrBoardNameReserved,
codeBoardNameTaken: feedback.ErrBoardNameTaken,
codeBoardDelisted: feedback.ErrBoardDelisted,
codeBoardNoLink: feedback.ErrBoardNoLink,
codeFeedbackAccount: feedback.ErrAccountRequired,
} {
b := &boardCalls{err: err}
rec := boardRoute(b, http.MethodPost, "/feedback/board/join", `{"displayName":" Sample User "}`, false)
var reason Reason
_ = json.Unmarshal(rec.Body.Bytes(), &reason)
if reason.Code != want {
t.Errorf("%v answered %d %q, want %q", err, rec.Code, reason.Code, want)
}
if b.joined != " Sample User " {
t.Errorf("the name reached the kernel as %q: the service, not the screen, decides what a name may be", b.joined)
}
}
if !errors.Is(feedback.ErrRouteAbsent, feedback.ErrUnavailable) {
t.Error("an absent route must stay an unavailable service")
}
}
+6 -4
View File
@@ -8,14 +8,16 @@ import (
type hostRoute struct{ method, path string }
// hostOnlyRoutes is the one list of routes a paired device may not use: they send
// data off this machine or destroy it, and belong to the window alone. The handler
// of each reads it, and so does the proxy that forwards a remote pane's requests
// with the host's own token, where the remote kernel would otherwise see the host
// and not the device. A HEAD request is judged as the GET it stands for.
// data off this machine, destroy it, or act for the account the window is signed in
// as. Each handler reads it, and so does the proxy that forwards a remote pane's
// requests with the host's own token. A HEAD request is judged as the GET it stands for.
var hostOnlyRoutes = []hostRoute{
{http.MethodGet, "/git/push"},
{http.MethodPost, "/git/push"},
{http.MethodPost, "/changes/discard"},
{http.MethodGet, "/feedback/board/me"},
{http.MethodPost, "/feedback/board/join"},
{http.MethodPost, "/feedback/board/leave"},
}
func hostOnlyRoute(method, p string) bool {
+133
View File
@@ -0,0 +1,133 @@
package feedback
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
)
// BoardEntry is one person on the contribution board: the name they chose, how
// many of their suggestions shipped, where, and (in a release view only) their
// place. Nothing else about them is ever on the wire.
type BoardEntry struct {
DisplayName string `json:"displayName"`
Credits int `json:"credits"`
Rank *int `json:"rank"`
Releases []BoardRelease `json:"releases"`
}
// BoardRelease is how many of a person's suggestions shipped in one release.
type BoardRelease struct {
Version string `json:"version"`
Count int `json:"count"`
}
// Board is the public contribution board. Available is false where the service
// serves none, which hides the section rather than failing it.
type Board struct {
Available bool `json:"available"`
View string `json:"view"`
Release *string `json:"release"`
Entries []BoardEntry `json:"entries"`
}
// BoardMe is what the signed-in person has chosen about being on the board.
type BoardMe struct {
Listed bool `json:"listed"`
DisplayName *string `json:"displayName"`
// Delisted: a maintainer removed this account, which cannot list itself
// again until it is restored.
Delisted bool `json:"delisted"`
}
// Board reads the public board, all releases or one. It needs no account. A
// service without the route answers Available false, so the section is simply
// not offered.
func (s *Service) Board(ctx context.Context, release string) (Board, error) {
path := "/v1/feedback/board"
if release != "" {
path += "?release=" + url.QueryEscape(release)
}
var out Board
err := s.withRetry(ctx, func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+path, nil)
if err != nil {
return err
}
return s.do(req, &out)
}, false)
if errors.Is(err, ErrRouteAbsent) {
return Board{Available: false, View: "all", Entries: []BoardEntry{}}, nil
}
if err != nil {
return Board{}, err
}
out.Available = true
if out.Entries == nil {
out.Entries = []BoardEntry{}
}
return out, nil
}
// BoardMe reads the account's own choice. It is the person's own question: the
// assertion is spent on this one call.
func (s *Service) BoardMe(ctx context.Context) (BoardMe, error) {
assertion, err := s.assertion(ctx)
if err != nil {
return BoardMe{}, err
}
var out BoardMe
err = s.linkCall(ctx, http.MethodGet, "/v1/feedback/board/me", assertion, nil, &out)
return out, err
}
// JoinBoard puts the account on the board under the name given, which only the
// person chooses. The service decides whether the name may be shown.
func (s *Service) JoinBoard(ctx context.Context, displayName string) (BoardMe, error) {
assertion, err := s.assertion(ctx)
if err != nil {
return BoardMe{}, err
}
var out BoardMe
if err := s.boardWrite(ctx, http.MethodPut, assertion, `{"displayName":`+jsonString(displayName)+`}`, &out); err != nil {
return BoardMe{}, err
}
return s.BoardMe(ctx)
}
// LeaveBoard takes the account off the board.
func (s *Service) LeaveBoard(ctx context.Context) (BoardMe, error) {
assertion, err := s.assertion(ctx)
if err != nil {
return BoardMe{}, err
}
var out struct{}
if err := s.boardWrite(ctx, http.MethodDelete, assertion, "", &out); err != nil {
return BoardMe{}, err
}
return s.BoardMe(ctx)
}
func jsonString(v string) string {
raw, _ := json.Marshal(v)
return string(raw)
}
// boardWrite sends the account's own choice to the board's opt-in route. Both
// verbs are idempotent, so an unreachable service is simply tried again.
func (s *Service) boardWrite(ctx context.Context, method, assertion, body string, out any) error {
return s.withRetry(ctx, func() error {
req, err := http.NewRequestWithContext(ctx, method, s.base+"/v1/feedback/board/optin", strings.NewReader(body))
if err != nil {
return err
}
req.Header.Set(assertionHeader, assertion)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
return s.do(req, out)
}, false)
}
+114
View File
@@ -0,0 +1,114 @@
package feedback
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
)
const sampleBoard = `{"view":"release","release":"v2.1.0","entries":[{"displayName":"Sample User","credits":2,"rank":1,"releases":[{"version":"v2.1.0","count":2}]}]}`
func TestTheBoardIsReadWithNoAccountAndAskedForTheReleaseNamed(t *testing.T) {
stub := &linkStub{body: sampleBoard}
s := linkService(t, stub, false)
got, err := s.Board(context.Background(), "v2.1.0")
if err != nil {
t.Fatal(err)
}
if !got.Available || got.Release == nil || *got.Release != "v2.1.0" || len(got.Entries) != 1 || got.Entries[0].DisplayName != "Sample User" || *got.Entries[0].Rank != 1 {
t.Fatalf("board = %+v", got)
}
req := stub.seen[0]
if req.Method != http.MethodGet || req.URL.Path != "/v1/feedback/board" || req.URL.Query().Get("release") != "v2.1.0" {
t.Fatalf("request = %s %s", req.Method, req.URL)
}
if req.Header.Get(assertionHeader) != "" || req.Header.Get("X-Install-Token") != "" {
t.Fatalf("a public read carried credentials: %v", req.Header)
}
}
func TestAServiceWithNoBoardRouteIsNotOfferedOneAndIsNotAnError(t *testing.T) {
s := linkService(t, &linkStub{code: http.StatusNotFound, body: "not found"}, false)
got, err := s.Board(context.Background(), "")
if err != nil || got.Available || len(got.Entries) != 0 {
t.Fatalf("board = %+v err = %v, want an empty answer that says unavailable", got, err)
}
down := linkService(t, &linkStub{code: http.StatusBadGateway, body: "bad gateway"}, false)
if _, err := down.Board(context.Background(), ""); err == nil {
t.Fatal("a failing service must not read as a service with no board")
}
}
func TestAnAbsentRouteIsStillAnUnavailableServiceToTheOtherCallers(t *testing.T) {
if !errors.Is(ErrRouteAbsent, ErrUnavailable) {
t.Fatal("ErrRouteAbsent must keep matching ErrUnavailable so existing callers read it as before")
}
s := linkService(t, &linkStub{code: http.StatusNotFound, body: "not found"}, true, "v1.a.b")
if _, err := s.Links(context.Background()); !errors.Is(err, ErrUnavailable) {
t.Fatalf("links against a service with no link route: %v", err)
}
}
func TestJoiningSendsTheNameWithTheAccountAssertionAndReadsBackTheChoice(t *testing.T) {
var methods []string
var bodies []string
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
methods = append(methods, r.Method+" "+r.URL.Path+" "+r.Header.Get(assertionHeader))
bodies = append(bodies, string(raw))
_, _ = io.WriteString(w, `{"listed":true,"displayName":"Sample User","delisted":false}`)
})
s := linkService(t, h, false, "v1.a.b")
me, err := s.JoinBoard(context.Background(), `Sample "User"`)
if err != nil || !me.Listed || me.DisplayName == nil {
t.Fatalf("me = %+v err = %v", me, err)
}
if len(methods) != 2 || methods[0] != "PUT /v1/feedback/board/optin v1.a.b" || methods[1] != "GET /v1/feedback/board/me v1.a.b" {
t.Fatalf("calls = %v", methods)
}
if !strings.Contains(bodies[0], `"displayName":"Sample \"User\""`) {
t.Fatalf("the name was not sent as JSON: %s", bodies[0])
}
if _, err := s.LeaveBoard(context.Background()); err != nil {
t.Fatal(err)
}
if methods[2] != "DELETE /v1/feedback/board/optin v1.a.b" {
t.Fatalf("leave = %v", methods)
}
}
func TestEachBoardRefusalKeepsItsOwnIdentity(t *testing.T) {
for code, want := range map[string]error{
"feedback.board_name_invalid": ErrBoardNameInvalid,
"feedback.board_name_reserved": ErrBoardNameReserved,
"feedback.board_name_taken": ErrBoardNameTaken,
"feedback.board_delisted": ErrBoardDelisted,
"feedback.board_no_link": ErrBoardNoLink,
} {
stub := &linkStub{code: http.StatusBadRequest, body: `{"error":{"code":"` + code + `","message":"x"}}`}
s := linkService(t, stub, false, "v1.a.b")
if _, err := s.JoinBoard(context.Background(), "Sample"); !errors.Is(err, want) {
t.Errorf("%s: %v, want %v", code, err, want)
}
}
}
func TestBoardWritesNeedTheAccountBeforeAnythingLeaves(t *testing.T) {
stub := &linkStub{body: `{}`}
s := linkService(t, stub, false, "")
if _, err := s.JoinBoard(context.Background(), "Sample"); !errors.Is(err, ErrAccountRequired) {
t.Fatalf("join: %v", err)
}
if _, err := s.LeaveBoard(context.Background()); !errors.Is(err, ErrAccountRequired) {
t.Fatalf("leave: %v", err)
}
if _, err := s.BoardMe(context.Background()); !errors.Is(err, ErrAccountRequired) {
t.Fatalf("me: %v", err)
}
if len(stub.seen) != 0 {
t.Fatalf("a request left without an account: %d", len(stub.seen))
}
}
+24 -16
View File
@@ -58,22 +58,27 @@ type wireError struct {
}
var codeSentinels = map[string]error{
"feedback.too_large": ErrTooLarge,
"feedback.rate_limited": ErrRateLimited,
"feedback.invalid": ErrInvalid,
"feedback.disabled": ErrDisabled,
"feedback.duplicate": ErrDuplicate,
"feedback.bad_token": ErrBadToken,
"feedback.busy": ErrBusy,
"feedback.image_metadata": ErrImageMetadata,
"feedback.reply_limit": ErrReplyLimit,
"feedback.not_replyable": ErrNotReplyable,
"feedback.challenge_required": ErrChallengeRequired,
"feedback.not_withdrawable": ErrNotWithdrawable,
"feedback.not_found": ErrNotFound,
"feedback.account_required": ErrAccountRequired,
"feedback.link_conflict": ErrLinkConflict,
"feedback.link_limit": ErrLinkLimit,
"feedback.too_large": ErrTooLarge,
"feedback.rate_limited": ErrRateLimited,
"feedback.invalid": ErrInvalid,
"feedback.disabled": ErrDisabled,
"feedback.duplicate": ErrDuplicate,
"feedback.bad_token": ErrBadToken,
"feedback.busy": ErrBusy,
"feedback.image_metadata": ErrImageMetadata,
"feedback.reply_limit": ErrReplyLimit,
"feedback.not_replyable": ErrNotReplyable,
"feedback.challenge_required": ErrChallengeRequired,
"feedback.not_withdrawable": ErrNotWithdrawable,
"feedback.not_found": ErrNotFound,
"feedback.account_required": ErrAccountRequired,
"feedback.link_conflict": ErrLinkConflict,
"feedback.link_limit": ErrLinkLimit,
"feedback.board_name_invalid": ErrBoardNameInvalid,
"feedback.board_name_reserved": ErrBoardNameReserved,
"feedback.board_name_taken": ErrBoardNameTaken,
"feedback.board_delisted": ErrBoardDelisted,
"feedback.board_no_link": ErrBoardNoLink,
}
func (s *Service) post(ctx context.Context, body wireSubmit, token string) (wireReceipt, error) {
@@ -185,6 +190,9 @@ func refusalOf(resp *http.Response, body []byte) error {
if resp.StatusCode >= 500 && resp.StatusCode < 600 {
return errTransientResponse
}
if resp.StatusCode == http.StatusNotFound {
return ErrRouteAbsent
}
return ErrUnavailable
}
}
+14 -2
View File
@@ -2,6 +2,7 @@ package feedback
import (
"errors"
"fmt"
"time"
)
@@ -36,8 +37,19 @@ var (
ErrLinkConflict = errors.New("feedback: install linked to another account")
ErrLinkLimit = errors.New("feedback: account link limit reached")
ErrNoInstall = errors.New("feedback: this install has not sent a report")
ErrOffline = errors.New("feedback: service unreachable")
ErrUnavailable = errors.New("feedback: service failed")
// The contribution-board sentinels: a display name the service refused as
// malformed, as reserved or as another person's, an account a maintainer
// removed, and a join before any install is linked. ErrRouteAbsent is a
// service that has no such route, which is a version or a switch, not a fault;
// it is also an ErrUnavailable.
ErrBoardNameInvalid = errors.New("feedback: board name is not allowed")
ErrBoardNameReserved = errors.New("feedback: board name is reserved")
ErrBoardNameTaken = errors.New("feedback: board name is taken")
ErrBoardDelisted = errors.New("feedback: account was removed from the board")
ErrBoardNoLink = errors.New("feedback: no install is linked to this account")
ErrRouteAbsent = fmt.Errorf("%w: the service has no such route", ErrUnavailable)
ErrOffline = errors.New("feedback: service unreachable")
ErrUnavailable = errors.New("feedback: service failed")
)
// Field names an InvalidError can carry.
+37
View File
@@ -24,6 +24,10 @@ type Feedback interface {
UnlinkFeedbackAccount(ctx context.Context) error
UnlinkFeedbackInstall(ctx context.Context, shortID string) error
FeedbackLinks(ctx context.Context) ([]feedback.LinkedInstall, error)
FeedbackBoard(ctx context.Context, release string) (feedback.Board, error)
FeedbackBoardMe(ctx context.Context) (feedback.BoardMe, error)
JoinFeedbackBoard(ctx context.Context, displayName string) (feedback.BoardMe, error)
LeaveFeedbackBoard(ctx context.Context) (feedback.BoardMe, error)
}
// FeedbackOptions is what assembly hands the controller for Feedback: the
@@ -141,3 +145,36 @@ func (c *Controller) FeedbackLinks(ctx context.Context) ([]feedback.LinkedInstal
}
return c.feedback.Service.Links(ctx)
}
// FeedbackBoard reads the public contribution board, all releases or one.
func (c *Controller) FeedbackBoard(ctx context.Context, release string) (feedback.Board, error) {
if c.feedback.Service == nil {
return feedback.Board{}, feedback.ErrDisabled
}
return c.feedback.Service.Board(ctx, release)
}
// FeedbackBoardMe reads the signed-in account's own choice about the board.
func (c *Controller) FeedbackBoardMe(ctx context.Context) (feedback.BoardMe, error) {
if c.feedback.Service == nil {
return feedback.BoardMe{}, feedback.ErrDisabled
}
return c.feedback.Service.BoardMe(ctx)
}
// JoinFeedbackBoard puts the account on the board under the name the person gave.
// It is only ever the answer to a person asking, and the service decides the name.
func (c *Controller) JoinFeedbackBoard(ctx context.Context, displayName string) (feedback.BoardMe, error) {
if c.feedback.Service == nil {
return feedback.BoardMe{}, feedback.ErrDisabled
}
return c.feedback.Service.JoinBoard(ctx, displayName)
}
// LeaveFeedbackBoard takes the account off the board.
func (c *Controller) LeaveFeedbackBoard(ctx context.Context) (feedback.BoardMe, error) {
if c.feedback.Service == nil {
return feedback.BoardMe{}, feedback.ErrDisabled
}
return c.feedback.Service.LeaveBoard(ctx)
}
@@ -0,0 +1,142 @@
package control
import (
"context"
"errors"
"fmt"
"strings"
"reasonix/internal/base/i18n"
"reasonix/internal/platform/feedback"
)
// feedbackBoardCommand runs /feedback board: the public list, and the person's own
// choice to be on it. Joining sends a name that becomes public, so it says what
// it would show and sends nothing until --yes follows the name.
func (c *Controller) feedbackBoardCommand(args string) {
fields := strings.Fields(args)
m := i18n.M.Feedback
switch {
case len(fields) == 0:
go c.feedbackBoardShow("")
case fields[0] == "leave" && len(fields) == 1:
go c.feedbackBoardChange(func(ctx context.Context) (feedback.BoardMe, error) { return c.LeaveFeedbackBoard(ctx) }, m.BoardLeaveDone)
case fields[0] == "me" && len(fields) == 1:
go c.feedbackBoardMe()
case fields[0] == "join":
c.feedbackBoardJoin(fields[1:])
case len(fields) == 1:
go c.feedbackBoardShow(fields[0])
default:
c.notice(feedbackUsage)
}
}
func (c *Controller) feedbackBoardShow(release string) {
ctx, cancel := context.WithTimeout(context.Background(), feedbackCommandTimeout)
defer cancel()
board, err := c.FeedbackBoard(ctx, release)
if err != nil {
c.notice(feedbackBoardFailure(err))
return
}
m := i18n.M.Feedback
if !board.Available {
c.notice(m.BoardOff)
return
}
view := m.BoardAllView
if board.Release != nil {
view = fmt.Sprintf(m.BoardReleaseFmt, *board.Release)
}
if len(board.Entries) == 0 {
c.notice(fmt.Sprintf(m.BoardHeaderFmt, view) + "\n" + m.BoardNone)
return
}
var b strings.Builder
b.WriteString(fmt.Sprintf(m.BoardHeaderFmt, view) + "\n")
for _, e := range board.Entries {
place := ""
if e.Rank != nil {
place = fmt.Sprintf("%d.", *e.Rank)
}
where := ""
if board.Release == nil {
var parts []string
for _, r := range e.Releases {
parts = append(parts, fmt.Sprintf("%s x%d", r.Version, r.Count))
}
if len(parts) > 0 {
where = fmt.Sprintf(m.BoardRowReleasesFmt, strings.Join(parts, ", "))
}
}
fmt.Fprintf(&b, m.BoardRowFmt+"\n", place, e.DisplayName, e.Credits, where)
}
c.notice(strings.TrimRight(b.String(), "\n"))
}
func (c *Controller) feedbackBoardMe() {
ctx, cancel := context.WithTimeout(context.Background(), feedbackCommandTimeout)
defer cancel()
me, err := c.FeedbackBoardMe(ctx)
if err != nil {
c.notice(feedbackBoardFailure(err))
return
}
m := i18n.M.Feedback
switch {
case me.Delisted:
c.notice(m.BoardDelisted)
case me.Listed && me.DisplayName != nil:
c.notice(fmt.Sprintf(m.BoardMeListedFmt, *me.DisplayName))
default:
c.notice(m.BoardMeNot)
}
}
func (c *Controller) feedbackBoardJoin(rest []string) {
m := i18n.M.Feedback
confirmed := false
if len(rest) > 0 && rest[len(rest)-1] == "--yes" {
confirmed, rest = true, rest[:len(rest)-1]
}
name := strings.Join(rest, " ")
if name == "" {
c.notice(feedbackUsage)
return
}
if !confirmed {
c.notice(fmt.Sprintf(m.BoardJoinExplainFmt, name))
return
}
go c.feedbackBoardChange(func(ctx context.Context) (feedback.BoardMe, error) { return c.JoinFeedbackBoard(ctx, name) }, m.BoardJoinDone)
}
func (c *Controller) feedbackBoardChange(act func(context.Context) (feedback.BoardMe, error), done string) {
ctx, cancel := context.WithTimeout(context.Background(), feedbackCommandTimeout)
defer cancel()
if _, err := act(ctx); err != nil {
c.notice(feedbackBoardFailure(err))
return
}
c.notice(done)
}
// feedbackBoardFailure speaks from the refusal's identity; anything else is the
// ordinary account-link failure.
func feedbackBoardFailure(err error) string {
m := i18n.M.Feedback
switch {
case errors.Is(err, feedback.ErrBoardNameInvalid):
return m.BoardNameInvalid
case errors.Is(err, feedback.ErrBoardNameReserved):
return m.BoardNameReserved
case errors.Is(err, feedback.ErrBoardNameTaken):
return m.BoardNameTaken
case errors.Is(err, feedback.ErrBoardDelisted):
return m.BoardDelisted
case errors.Is(err, feedback.ErrBoardNoLink):
return m.BoardNoLink
}
return feedbackLinkFailure(err)
}
@@ -23,6 +23,8 @@ const feedbackUsage = `usage:
/feedback link [--yes] show your reports on every device you link to your account
/feedback unlink [--yes] stop linking this device
/feedback links list the devices linked to your account
/feedback board [release] show the contribution board (all releases, or one release ranked)
/feedback board join <name> [--yes] | leave | me choose to be listed on it, or leave it
/feedback name <nickname> set the nickname reports go out under`
const feedbackPublicNotice = "Your text and nickname become PUBLIC on GitHub (esengine/DeepSeek-Reasonix) only if we file your report as an issue; otherwise they stay private. You will get a receipt and, if it is filed, the link; we may also reply or ask questions here (/feedback list). Do not include secrets or private code. Security problems belong in SECURITY.md, not here."
@@ -43,6 +45,7 @@ func feedbackArgItems(prior []string) []SlashItem {
{Label: "link", Insert: "link", Hint: i18n.M.ArgFeedbackLink},
{Label: "unlink", Insert: "unlink", Hint: i18n.M.ArgFeedbackUnlink},
{Label: "links", Insert: "links", Hint: i18n.M.ArgFeedbackLinks},
{Label: "board", Insert: "board", Hint: i18n.M.ArgFeedbackBoard},
{Label: "name", Insert: "name", Hint: i18n.M.ArgFeedbackName},
}
}
@@ -75,6 +78,8 @@ func (c *Controller) feedbackCommand(args string) {
c.feedbackLinkCommand(rest, false)
case "links":
go c.feedbackLinks()
case "board":
c.feedbackBoardCommand(rest)
case "name":
if err := c.SetFeedbackDisplayName(rest); err != nil {
c.notice(feedbackFailure(err))
@@ -168,7 +168,7 @@ func TestFeedbackIsCompletedAsACommand(t *testing.T) {
for _, it := range items {
labels = append(labels, it.Label)
}
if strings.Join(labels, ",") != "bug,idea,question,other,list,show,reply,withdraw,link,unlink,links,name" {
if strings.Join(labels, ",") != "bug,idea,question,other,list,show,reply,withdraw,link,unlink,links,board,name" {
t.Fatalf("labels = %v", labels)
}
found := false
@@ -49,6 +49,10 @@ func newLinkRig(t *testing.T, assertion string) *linkRig {
_, _ = io.WriteString(w, linkRigAccount)
case req.URL.Path == "/v1/feedback/link" && req.Method == http.MethodGet:
_, _ = io.WriteString(w, `{"installs":[{"id":"aaaaaaaaaaaa","linkedAt":"2026-10-02T00:00:00Z","current":true},{"id":"bbbbbbbbbbbb","linkedAt":"2026-10-03T00:00:00Z","current":false}]}`)
case req.URL.Path == "/v1/feedback/board" && req.Method == http.MethodGet:
_, _ = io.WriteString(w, `{"view":"release","release":"v2.1.0","entries":[{"displayName":"Sample User","credits":2,"rank":1,"releases":[{"version":"v2.1.0","count":2}]},{"displayName":"Other Sample","credits":1,"rank":2,"releases":[{"version":"v2.1.0","count":1}]}]}`)
case req.URL.Path == "/v1/feedback/board/me":
_, _ = io.WriteString(w, `{"listed":true,"displayName":"Sample User","delisted":false}`)
default:
_, _ = io.WriteString(w, "{}")
}
@@ -177,3 +181,51 @@ func TestFeedbackLinkWordsAreInEveryCatalogue(t *testing.T) {
}
}
}
func TestFeedbackBoardListsWithPlacesForAReleaseAndNeedsNoAccountToRead(t *testing.T) {
r := newLinkRig(t, "")
r.c.Submit("/feedback board v2.1.0")
got := r.say(t, "Sample User")
if !strings.Contains(got, "1. Sample User - 2 adopted") || !strings.Contains(got, "2. Other Sample - 1 adopted") {
t.Fatalf("board = %q", got)
}
if r.called("/v1/feedback/board v") != 0 || strings.Contains(strings.Join(r.calls, "\n"), "GET /v1/feedback/board assert") {
t.Fatalf("a public read carried an assertion: %v", r.calls)
}
}
func TestFeedbackBoardJoinSaysTheNameFirstAndSendsNothingUntilYes(t *testing.T) {
r := newLinkRig(t, "assert-1")
r.c.Submit("/feedback board join Sample Person")
r.say(t, "\"Sample Person\"")
if r.called("PUT /v1/feedback/board/optin") != 0 {
t.Fatal("the account was put on the board without --yes")
}
r.c.Submit("/feedback board join Sample Person --yes")
r.say(t, "You are on the board")
if r.called("PUT /v1/feedback/board/optin assert-1") != 1 {
t.Fatalf("the join did not carry the assertion:\n%v", r.calls)
}
r.c.Submit("/feedback board leave")
r.say(t, "You are off the board")
if r.called("DELETE /v1/feedback/board/optin assert-1") != 1 {
t.Fatalf("the leave did not carry the assertion:\n%v", r.calls)
}
}
func TestFeedbackBoardJoinSignedOutAndRefusedAreSaidFromTheirIdentity(t *testing.T) {
out := newLinkRig(t, "")
out.c.Submit("/feedback board join Sample --yes")
out.say(t, "reasonix login")
if out.called("/v1/feedback/board/optin") != 0 {
t.Fatal("a request went out with no sign-in")
}
r := newLinkRig(t, "assert-1")
for code, says := range map[string]string{"feedback.board_name_reserved": "reserved", "feedback.board_name_taken": "already uses", "feedback.board_delisted": "removed", "feedback.board_no_link": "/feedback link"} {
r.mu.Lock()
r.refuse["PUT /v1/feedback/board/optin"] = code
r.mu.Unlock()
r.c.Submit("/feedback board join Sample --yes")
r.say(t, says)
}
}
@@ -0,0 +1,2 @@
- 新增:反馈面板里的“贡献榜”,按版本有名次,自己选择加入并预览名字,随时退出;终端用 /feedback board。服务端开启后才显示。关联 #13142。
- Added: A contribution board in the feedback panel, ranked per release; you choose to join, preview the name and can leave. Terminal: /feedback board. Refs #13142.
+4
View File
@@ -259,6 +259,10 @@ var mirroredWireTypes = []wireMirror{
{"internal/platform/feedback/types.go", "ReplyReceipt", tsFeedbackFile, "FeedbackReplyReceipt"},
{"internal/platform/feedback/types.go", "Mine", tsFeedbackFile, "FeedbackMine"},
{"internal/platform/feedback/types.go", "Promotion", tsFeedbackFile, "FeedbackPromotion"},
{"internal/platform/feedback/board.go", "Board", tsFeedbackFile, "FeedbackBoard"},
{"internal/platform/feedback/board.go", "BoardEntry", tsFeedbackFile, "FeedbackBoardEntry"},
{"internal/platform/feedback/board.go", "BoardRelease", tsFeedbackFile, "FeedbackBoardRelease"},
{"internal/platform/feedback/board.go", "BoardMe", tsFeedbackFile, "FeedbackBoardMe"},
{"internal/platform/feedback/types.go", "Profile", tsFeedbackFile, "FeedbackProfile"},
{"internal/platform/feedback/types.go", "EffectiveLimits", tsFeedbackFile, "FeedbackEffectiveLimits"},
{"internal/frontend/serve/feedback.go", "feedbackEnvView", tsFeedbackFile, "FeedbackEnv"},