diff --git a/packages/web/e2e/push-banner.spec.ts b/packages/web/e2e/push-banner.spec.ts new file mode 100644 index 0000000..247205c --- /dev/null +++ b/packages/web/e2e/push-banner.spec.ts @@ -0,0 +1,108 @@ +import { PUSH_GRACE_MS as GRACE_MS } from "../src/state/reconnect.js"; +import type { Locator } from "@playwright/test"; +import { expect, login, openBot, test, type Page } from "./fixtures"; + +declare global { + interface Window { + __pushBannerSeen?: boolean; + } +} + +const refusePush = async (page: Page, refused: boolean) => { + expect((await page.request.post("/api/test/sockets/refuse", { data: { refused } })).status()).toBe(204); +}; + +const dropPush = async (page: Page) => { + expect((await page.request.post("/api/test/sockets/drop")).status()).toBe(204); +}; + +const boxOf = async (locator: Locator) => { + const box = await locator.boundingBox(); + if (box === null) throw new Error("元素没有渲染出来"); + return box; +}; + +const overlaps = async (a: Locator, b: Locator) => { + const [x, y] = [await boxOf(a), await boxOf(b)]; + return x.x < y.x + y.width && y.x < x.x + x.width && x.y < y.y + y.height && y.y < x.y + x.height; +}; + +const postMissed = async (page: Page, text: string) => { + const posted = await page.request.post("/api/test/messages", { data: { groupId: "grp_sales", text } }); + expect(posted.status(), "断线时服务端多了一条推不过来的消息").toBe(204); +}; + +test("推送断线超过宽限期,顶上说连不上、正在重试;连回来提示消失,断线期间的消息补拉出来", async ({ page }) => { + const MISSED = "推送断着的时候别的窗口说的话"; + await login(page); + await openBot(page, "小销"); + const list = page.getByTestId("message-list"); + await expect(list.getByTestId("message"), "预置的历史到齐了").not.toHaveCount(0); + const banner = page.getByTestId("push-banner"); + await expect(banner, "连着的时候不提示").toHaveCount(0); + + await refusePush(page, true); + await dropPush(page); + await expect(banner, "断线超过宽限期就提示").toBeVisible(); + await expect(banner).toContainText("连不上"); + await expect(banner).toContainText("重试"); + await expect( + page.locator("[aria-live=polite]").filter({ has: banner }), + "提示放在礼貌播报的区域里,读屏会念出来", + ).toHaveCount(1); + for (const [testId, name] of [ + ["bot-edit", "编辑"], + ["bot-share", "分享"], + ] as const) { + const button = page.getByTestId(testId); + await expect(button, `断线时聊天头的「${name}」还看得见`).toBeVisible(); + await expect(button).toBeEnabled(); + await button.click({ trial: true }); + expect(await overlaps(banner, button), `断线提示不压在「${name}」上`).toBe(false); + } + + await postMissed(page, MISSED); + await expect(list.locator("[data-kind=user]", { hasText: MISSED }), "断着的时候推不过来").toHaveCount(0); + await expect(banner, "一直连不上就一直提示").toBeVisible(); + + await refusePush(page, false); + await expect(banner, "连回来提示就收起").toHaveCount(0, { timeout: 15_000 }); + await expect(list.locator("[data-kind=user]", { hasText: MISSED }), "连回来补拉出断线期间的消息").toHaveCount(1); +}); + +test("推送断一下马上连回来,提示不闪出来", async ({ page }) => { + const MISSED = "一闪而过的断线里说的话"; + await page.addInitScript(() => { + window.__pushBannerSeen = false; + new MutationObserver(() => { + if (document.querySelector("[data-testid=push-banner]") !== null) window.__pushBannerSeen = true; + }).observe(document, { childList: true, subtree: true }); + }); + await login(page); + await openBot(page, "小销"); + const list = page.getByTestId("message-list"); + await expect(list.getByTestId("message"), "预置的历史到齐了").not.toHaveCount(0); + + const droppedAt = Date.now(); + await dropPush(page); + await postMissed(page, MISSED); + await expect(list.locator("[data-kind=user]", { hasText: MISSED }), "推送已经连回来了").toHaveCount(1); + expect(Date.now() - droppedAt, "这一趟重连要快过宽限期,否则测不出闪不闪").toBeLessThan(GRACE_MS); + await page.waitForTimeout(droppedAt + GRACE_MS + 500 - Date.now()); + expect(await page.evaluate(() => window.__pushBannerSeen), "宽限期内连回来,提示一次都没出现过").toBe(false); +}); + +test("断线提示和顶上的浮动提示同时出现时各占各的位置,不叠在一起", async ({ page }) => { + await login(page); + await page.goto("/?oauth=x&result=failed"); + const notice = page.getByTestId("oauth-notice"); + await expect(notice).toBeVisible(); + + await refusePush(page, true); + await dropPush(page); + const banner = page.getByTestId("push-banner"); + await expect(banner).toBeVisible(); + await expect(notice, "断线不会把授权结果的提示挤掉").toBeVisible(); + expect(await overlaps(banner, notice), "授权结果的提示让到断线提示下面").toBe(false); + await notice.getByTestId("oauth-notice-close").click({ trial: true }); +}); diff --git a/packages/web/mock/routes/test.ts b/packages/web/mock/routes/test.ts index cba51e5..cd6d44b 100644 --- a/packages/web/mock/routes/test.ts +++ b/packages/web/mock/routes/test.ts @@ -92,10 +92,14 @@ const TestDeltaRequest = z const LegacyDescriptionRequest = z.object({ botId: BotId, description: z.string() }).strict(); const ManyPreferencesRequest = z.object({ botId: BotId, count: z.number().int().min(1).max(100) }).strict(); const ComputerStatusRequest = z.object({ status: ComputerStatus }).strict(); +const PushRefusalRequest = z.object({ refused: z.boolean() }).strict(); const activeTurns = new Map>(); const turnEpochs = new Map(); +let pushRefused = false; + export const currentTurns = (): Iterable> => activeTurns.values(); +export const refusingPush = (): boolean => pushRefused; export const testRoutes: Route = async ({ req, res, path, method, push }) => { if (method === "GET" && path === "/api/health") { @@ -109,11 +113,22 @@ export const testRoutes: Route = async ({ req, res, path, method, push }) => { resetRegisteredEmails(); resetLive(); activeTurns.clear(); + pushRefused = false; turnEpochs.clear(); send(res, 204); return true; } + if (method === "POST" && path === "/api/test/sockets/refuse") { + const parsed = PushRefusalRequest.safeParse(await readBody(req)); + if (!parsed.success) send(res, 400, { error: "要写明拒不拒推送连接" }); + else { + pushRefused = parsed.data.refused; + send(res, 204); + } + return true; + } + // 写入上限收紧之前存下的长描述,PATCH 造不出来,只能从这里直接种。 if (method === "POST" && path === "/api/test/bot-description") { const parsed = LegacyDescriptionRequest.safeParse(await readBody(req)); diff --git a/packages/web/mock/server.ts b/packages/web/mock/server.ts index 4a2dd28..478c2bd 100644 --- a/packages/web/mock/server.ts +++ b/packages/web/mock/server.ts @@ -20,7 +20,7 @@ import { hookRoutes, routineRoutes } from "./routes/routines.js"; import { skillRoutes } from "./routes/skills.js"; import { teamOfferRoutes } from "./routes/team-offers.js"; import { templateRoutes } from "./routes/templates.js"; -import { currentTurns, testRoutes } from "./routes/test.js"; +import { currentTurns, refusingPush, testRoutes } from "./routes/test.js"; import { todayRoutes } from "./routes/today.js"; import { toolRoutes } from "./routes/tools.js"; @@ -99,6 +99,10 @@ server.on("upgrade", (req, socket, head) => { socket.destroy(); return; } + if (live === null && refusingPush()) { + socket.destroy(); + return; + } if (live !== null) { wss.handleUpgrade(req, socket, head, (ws) => attachLiveViewer(ws, live[1] ?? "", push)); return; diff --git a/packages/web/src/components/PushBanner.tsx b/packages/web/src/components/PushBanner.tsx new file mode 100644 index 0000000..479f19d --- /dev/null +++ b/packages/web/src/components/PushBanner.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; +import { WifiOff } from "lucide-react"; +import { PUSH_GRACE_MS } from "../state/reconnect.js"; +import type { PushLink } from "../state/types.js"; + +// 播报区域一直挂着,读屏要先认识这块区域,后来塞进去的字才会念。 +export const PushBanner = ({ link }: { link: PushLink }) => { + const [lagging, setLagging] = useState(false); + useEffect(() => { + if (link === "open") return; + const timer = setTimeout(() => setLagging(true), PUSH_GRACE_MS); + return () => { + clearTimeout(timer); + setLagging(false); + }; + }, [link]); + + return ( +
+ {lagging ? ( +
+
+ ) : null} +
+ ); +}; diff --git a/packages/web/src/routes/MainWindow.tsx b/packages/web/src/routes/MainWindow.tsx index 3303eb9..4ad95be 100644 --- a/packages/web/src/routes/MainWindow.tsx +++ b/packages/web/src/routes/MainWindow.tsx @@ -13,6 +13,7 @@ import { SettingsWindow } from "../components/SettingsWindow.js"; import { TaskPanel } from "../components/TaskPanel.js"; import { ToolInspector } from "../components/ToolInspector.js"; import { BotCreatedNotice } from "../components/BotCreatedNotice.js"; +import { PushBanner } from "../components/PushBanner.js"; import { LiveHost } from "../live/LiveHost.js"; import { TodayPage } from "./TodayPage.js"; import { PokerProjectPage } from "./PokerProjectPage.js"; @@ -233,6 +234,7 @@ export const MainWindow = () => { return (
+ { socket = new WebSocket(`${scheme}://${window.location.host}/api/ws`); socket.addEventListener("open", () => { failures = 0; + put({ type: "push_link", link: "open" }); if (hasConnected) { put({ type: "push_reconnected", seq: nextSeq() }); refreshPendingCounts(); @@ -1031,6 +1032,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { // 分不出是登录过期还是断网,拿这次会话打一下 /api/me 问清楚:401 回登录页,别的按退避再连。 socket.addEventListener("close", () => { if (!live()) return; + put({ type: "push_link", link: "down" }); scoped.api.me().then( () => { if (live()) retryLater(); diff --git a/packages/web/src/state/reconnect.ts b/packages/web/src/state/reconnect.ts index 46f3b98..59c7b0b 100644 --- a/packages/web/src/state/reconnect.ts +++ b/packages/web/src/state/reconnect.ts @@ -3,3 +3,6 @@ const MAX_MS = 30_000; // 推送连着失败 failures 次之后等多久再连:1 秒起,每失败一次翻倍,封顶 30 秒。 export const reconnectDelay = (failures: number): number => Math.min(FIRST_MS * 2 ** failures, MAX_MS); + +// 断线后至少要等过第一次重连,还没连回来才值得提示。 +export const PUSH_GRACE_MS = 2 * reconnectDelay(0); diff --git a/packages/web/src/state/reducer.ts b/packages/web/src/state/reducer.ts index dd29063..dfed9de 100644 --- a/packages/web/src/state/reducer.ts +++ b/packages/web/src/state/reducer.ts @@ -58,6 +58,7 @@ const enteredState = (action: Extract): ReadyState => usage, content: EMPTY_CONTENT, todayVersion: 0, + pushLink: "down", reconnectedAt: 0 as Seq, messagesLoadedAt: {}, messageGaps: {}, @@ -91,6 +92,7 @@ export const reduce = (state: AppState, action: Action): AppState => { reconnectedAt: action.seq, messageGaps: rearmGaps(state.messageGaps), }; + if (action.type === "push_link") return { ...state, pushLink: action.link }; if (action.type === "load_status") return { ...state, loads: { ...state.loads, [action.key]: action.status } }; const groups = reduceGroups(state, action); const approvals = reduceApprovals(groups, action); diff --git a/packages/web/src/state/types.ts b/packages/web/src/state/types.ts index ce5f5dc..33d744d 100644 --- a/packages/web/src/state/types.ts +++ b/packages/web/src/state/types.ts @@ -143,6 +143,8 @@ export type GroupTurn = Extract; export type TeamOfferOutcome = "confirmed" | "dismissed"; +export type PushLink = "down" | "open"; + export type AppState = | { status: "loading" } | { status: "anonymous"; login: LoginPhase; register: LoginPhase } @@ -188,6 +190,7 @@ export type AppState = usage: UsageResponse; content: ContentBook; todayVersion: number; + pushLink: PushLink; // 推送最近一次重连时取的号,messagesLoadedAt 是每个群的历史请求发出时取的号。比重连早的历史可能漏了断线时的推送。 reconnectedAt: Seq; messagesLoadedAt: Record; @@ -293,6 +296,7 @@ export type Action = | { type: "publications_loaded"; publications: Publication[] } | { type: "today_version_bumped" } | { type: "push_reconnected"; seq: Seq } + | { type: "push_link"; link: PushLink } | { type: "load_status"; key: LoadKey; status: LoadStatus } | { type: "from_session"; session: SessionId; action: Action }; diff --git a/packages/web/src/styles/03-status.css b/packages/web/src/styles/03-status.css index 996ce55..bcf9e8f 100644 --- a/packages/web/src/styles/03-status.css +++ b/packages/web/src/styles/03-status.css @@ -71,3 +71,38 @@ animation: none; } } + +/* 断线提示可能一直挂着,占住内容区顶上一行,不浮在聊天头的按钮上。定高不换行,浮动提示才知道该让多少。 */ +.push-status { + grid-column: 2 / -1; + grid-row: 1; +} + +.push-banner { + display: flex; + gap: var(--space-4); + align-items: center; + justify-content: center; + height: var(--control-h-md); + padding: 0 var(--space-6); + overflow: hidden; + border-bottom: 1px solid var(--border-1); + background: var(--warning-soft); + color: var(--fg-1); + font: var(--type-ui); + white-space: nowrap; +} + +.push-banner span { + overflow: hidden; + text-overflow: ellipsis; +} + +.push-banner svg { + flex: none; + color: var(--warning); +} + +.window:has(.push-banner) > .oauth-notice { + top: calc(var(--control-h-md) + var(--space-6)); +} diff --git a/packages/web/src/styles/04-sidebar.css b/packages/web/src/styles/04-sidebar.css index 7df1743..7598dc5 100644 --- a/packages/web/src/styles/04-sidebar.css +++ b/packages/web/src/styles/04-sidebar.css @@ -3,12 +3,14 @@ .window { display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr) var(--panel-w); + grid-template-rows: auto 1fr; height: 100%; background: var(--bg-app); } .sidebar { display: flex; + grid-row: 1 / -1; flex-direction: column; gap: var(--space-1); min-height: 0;