Merge pull request #1194 from J3vb/feat/dm-block-gating-updater-progress

feat(client): DM block composer gating + updater download progress
This commit is contained in:
J3vb
2026-07-20 12:39:35 +02:00
committed by GitHub
16 changed files with 553 additions and 32 deletions
@@ -1,6 +1,6 @@
use std::sync::Arc;
use serde::Serialize;
use tauri::AppHandle;
use tauri::{AppHandle, Emitter};
use tauri_plugin_updater::UpdaterExt;
use crate::livekit_proxy::{cert_store_key, load_stored_fingerprint, PinnedVerifier};
@@ -12,6 +12,15 @@ pub struct UpdateCheckResult {
pub body: Option<String>,
}
/// Download progress, emitted to the webview as `update-progress` so the banner
/// can show a percentage/bytes instead of looking hung. `total` is None until
/// the server sends a Content-Length.
#[derive(Clone, Serialize)]
struct DownloadProgress {
received: u64,
total: Option<u64>,
}
/// Extract the host (with port if non-443) from an https:// URL for cert store lookup.
fn extract_host_for_cert_store(server_url: &str) -> Result<String, String> {
let parsed = url::Url::parse(server_url)
@@ -174,9 +183,20 @@ pub async fn download_and_install_update(
match update {
Some(u) => {
u.download_and_install(|_chunk_len, _total| {}, || {})
.await
.map_err(|e| format!("download/install failed: {e}"))?;
// Accumulate downloaded bytes and emit progress to the webview.
// A failed emit must never abort the install, hence `let _ =`.
let progress_app = app.clone();
let mut received: u64 = 0;
u.download_and_install(
move |chunk_len, total| {
received += chunk_len as u64;
let _ =
progress_app.emit("update-progress", DownloadProgress { received, total });
},
|| {},
)
.await
.map_err(|e| format!("download/install failed: {e}"))?;
Ok(())
}
None => Err("no update available".into()),
@@ -4,6 +4,7 @@
import { createElement, appendChildren } from "@lib/dom";
import { createLogger } from "@lib/logger";
import { checkForUpdate, downloadAndInstallUpdate } from "@lib/updater";
import type { DownloadProgress } from "@lib/updater";
import type { MountableComponent } from "@lib/safe-render";
const log = createLogger("update-notifier");
@@ -12,6 +13,19 @@ export interface UpdateNotifierOptions {
readonly serverUrl: string;
}
/**
* Human-readable download status. Shows a percentage when the total size is
* known, otherwise the bytes received so the banner never looks hung.
*/
export function formatDownloadProgress(p: DownloadProgress): string {
if (p.total !== null && p.total > 0) {
const pct = Math.min(100, Math.max(0, Math.round((p.received / p.total) * 100)));
return `Downloading update… ${pct}%`;
}
const mb = (p.received / (1024 * 1024)).toFixed(1);
return `Downloading update… ${mb} MB`;
}
export function createUpdateNotifier(options: UpdateNotifierOptions): MountableComponent {
const { serverUrl } = options;
let container: Element | null = null;
@@ -66,15 +80,13 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
// Replace banner content with progress indicator
while (banner.firstChild) banner.removeChild(banner.firstChild);
const progress = createElement(
"span",
{ class: "update-banner-text" },
"Downloading update...",
);
const progress = createElement("span", { class: "update-banner-text" }, "Downloading update…");
banner.appendChild(progress);
try {
await downloadAndInstallUpdate(serverUrl);
await downloadAndInstallUpdate(serverUrl, (p) => {
progress.textContent = formatDownloadProgress(p);
});
// App will relaunch — this code won't execute after relaunch()
} catch (err) {
log.error("Update install failed", { error: String(err) });
+6
View File
@@ -22,6 +22,7 @@ import type {
MemberResponse,
DmChannelsResponse,
CreateDmResponse,
BlockedUsersResponse,
} from "./types";
/** Configuration for the API client. */
@@ -445,6 +446,11 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return request<void>("DELETE", `/dms/${channelId}`, undefined, signal);
},
/** List recipient user IDs the current user has blocked. */
listBlocks(signal?: AbortSignal): Promise<BlockedUsersResponse> {
return request<BlockedUsersResponse>("GET", "/blocks", undefined, signal);
},
// ── Voice ─────────────────────────────────────────────
getVoiceCredentials(signal?: AbortSignal): Promise<VoiceCredentialsResponse> {
+33 -1
View File
@@ -52,7 +52,9 @@ import {
updateDmLastMessagePreview,
} from "@stores/dm.store";
import type { DmChannel } from "@stores/dm.store";
import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store";
import type { DmChannelPayload } from "./types";
import type { ApiClient } from "./api";
import {
handleVoiceToken,
handleE2EEAnnounce,
@@ -99,8 +101,14 @@ export function wireConnectionStatus(ws: Pick<WsClient, "onStateChange">): () =>
/**
* Wire a WsClient to all domain stores.
* Returns a cleanup function that removes all listeners.
*
* `api` is optional so tests can wire the dispatcher without a client; when
* present it is used to refresh DM block state (GET /blocks) on ready.
*/
export function wireDispatcher(ws: WsClient): DispatcherCleanup {
export function wireDispatcher(
ws: WsClient,
api?: Pick<ApiClient, "listBlocks">,
): DispatcherCleanup {
const unsubs: Array<() => void> = [];
// ── Auth ──────────────────────────────────────────────
@@ -156,6 +164,17 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
setDmChannels(dmPayloads.map(mapDmPayload));
}
// Refresh DM block state (channels-members-dms.md §3.2). "Being blocked"
// is only known from a refused send, so it's stale after a reconnect —
// clear it and re-fetch our own outgoing blocks authoritatively.
clearBlockedByThem();
if (api !== undefined) {
api
.listBlocks()
.then((r) => setBlockedByMe(r.blocked_user_ids))
.catch((err) => log.warn("Failed to load block list", { error: String(err) }));
}
log.info("Ready payload applied", {
channels: payload.channels.length,
members: payload.members.length,
@@ -460,6 +479,19 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
// that specific row failed (with retry) instead of a global toast. This
// covers SLOW_MODE, FORBIDDEN, RATE_LIMITED, BAD_REQUEST, etc. on send.
if (id && messagesStore.getState().pendingSends.has(id)) {
// A FORBIDDEN on a DM send is the server's generic block refusal
// (ErrBlocked → FORBIDDEN, bidirectional). Gate the composer with the
// neutral "being blocked" reason; blocks.store precedence still shows
// the explicit reason if we are the blocker. Read the channel before
// markSendFailed clears the pending row.
if (payload.code === "FORBIDDEN") {
const chId = messagesStore.getState().pendingSends.get(id);
const dm =
chId === undefined
? undefined
: dmStore.getState().channels.find((c) => c.channelId === chId);
if (dm !== undefined) setUserBlockedByThem(dm.recipient.id, true);
}
markSendFailed(id, payload.code);
return;
}
+5
View File
@@ -650,6 +650,11 @@ export interface CreateDmResponse {
readonly created: boolean;
}
/** GET /api/v1/blocks response. */
export interface BlockedUsersResponse {
readonly blocked_user_ids: readonly number[];
}
/** TURN/STUN credentials from GET /api/voice/credentials. */
export interface IceServer {
readonly urls: string;
+27 -3
View File
@@ -14,6 +14,12 @@ export interface UpdateCheckResult {
readonly body: string | null;
}
/** Download progress reported by the Rust updater during install. */
export interface DownloadProgress {
readonly received: number;
readonly total: number | null;
}
/** Check if a newer client version is available on the connected server. */
export async function checkForUpdate(serverUrl: string): Promise<UpdateCheckResult> {
try {
@@ -32,10 +38,28 @@ export async function checkForUpdate(serverUrl: string): Promise<UpdateCheckResu
}
}
/** Download and install a pending update, then relaunch the app. */
export async function downloadAndInstallUpdate(serverUrl: string): Promise<void> {
/**
* Download and install a pending update, then relaunch the app.
* `onProgress`, when given, is fed the Rust updater's `update-progress` events
* so the caller can show download progress instead of a hung spinner.
*/
export async function downloadAndInstallUpdate(
serverUrl: string,
onProgress?: (progress: DownloadProgress) => void,
): Promise<void> {
log.info("Downloading and installing update...");
await invoke("download_and_install_update", { serverUrl });
let unlisten: (() => void) | undefined;
if (onProgress !== undefined) {
const { listen } = await import("@tauri-apps/api/event");
unlisten = await listen<DownloadProgress>("update-progress", (event) => {
onProgress({ received: event.payload.received, total: event.payload.total ?? null });
});
}
try {
await invoke("download_and_install_update", { serverUrl });
} finally {
unlisten?.();
}
log.info("Update installed, relaunching...");
await relaunch();
}
+1 -1
View File
@@ -240,7 +240,7 @@ function renderPage(pageId: "connect" | "main"): void {
lastConnectHost = host;
lastConnectToken = token;
ws.connect({ host, token });
dispatcherCleanup = wireDispatcher(ws);
dispatcherCleanup = wireDispatcher(ws, api);
log.info("Dispatcher wired, connecting WS");
// BUG-135: Only persist credentials when the user opted in.
@@ -30,6 +30,7 @@ import type { ReactionController } from "./ReactionController";
import { updateChatHeaderForDm } from "./ChatHeader";
import type { ChatHeaderRefs } from "./ChatHeader";
import { dmStore } from "@stores/dm.store";
import { blocksStore, dmComposerBlockReason } from "@stores/blocks.store";
import { membersStore } from "@stores/members.store";
import { channelsStore } from "@stores/channels.store";
import { uiStore } from "@stores/ui.store";
@@ -314,14 +315,20 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
// Composer gating: express permission + connection as affordance. The
// composer disables (with a reason) when the socket is down or the user
// may not post here, instead of accepting a click and failing. DM channels
// are not in channelsStore (dmStore), so they are left ungated here — the
// server still enforces block/permission and a refused send shows as a
// failed row.
// may not post here, instead of accepting a click and failing. For DM
// channels the reason also covers block state (channels-members-dms.md §3.2).
const dmRecipientId =
channelType === "dm"
? (dmStore.getState().channels.find((c) => c.channelId === channelId)?.recipient.id ?? null)
: null;
const computeComposerReason = (): string | null => {
const status = uiStore.getState().connectionStatus;
if (status === "reconnecting") return "Reconnecting…";
if (status === "disconnected") return "Not connected";
if (dmRecipientId !== null) {
const blockReason = dmComposerBlockReason(blocksStore.getState(), dmRecipientId);
if (blockReason !== null) return blockReason;
}
const ch = channelsStore.getState().channels.get(channelId);
if (ch === undefined) return null;
if (!ch.canSend) {
@@ -347,6 +354,15 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
() => refreshComposerState(),
),
);
if (dmRecipientId !== null) {
// Un-gate live when the block clears (unblock) and gate on a refused send.
composerGatingUnsubs.push(
blocksStore.subscribeSelector(
(s) => dmComposerBlockReason(s, dmRecipientId),
() => refreshComposerState(),
),
);
}
// Arrow-up edit: listen for edit-last-message bubbling from MessageInput
slots.inputSlot.addEventListener(
@@ -72,8 +72,8 @@ export function addDmToChannelsStore(dmChannel: DmChannel): void {
position: 0,
unreadCount: dmChannel.unreadCount,
lastMessageId: dmChannel.lastMessageId,
// DMs are always postable from the client; the server enforces block state,
// and a refused send surfaces as a failed row rather than a disabled composer.
// Channel-level permission is always true for DMs; block state is layered on
// top by the composer via blocks.store (see ChannelController), not canSend.
canSend: true,
};
channelsStore.setState((prev) => {
@@ -0,0 +1,64 @@
/**
* Blocks store — holds DM block state so the composer can gate on it.
* Immutable state updates only.
*
* Two directions, per channels-members-dms.md §3.2:
* - blockedByMe: recipient userIds the local user has blocked (authoritative,
* from GET /blocks). Shows "You've blocked this user…".
* - blockedByThem: recipient userIds whose DM refused a send (the server returns
* a generic FORBIDDEN, so this is learned from a refused send,
* not revealed up front). Shows the neutral "You can't message…".
* Cleared on every ready so a fresh session re-evaluates.
*/
import { createStore } from "@lib/store";
/** Shown when the local user is the blocker. */
export const BLOCKED_BY_ME_REASON = "You've blocked this user. Unblock to send messages.";
/** Neutral reason for the blocking direction — never reveals the block explicitly. */
export const BLOCKED_BY_THEM_REASON = "You can't message this user right now.";
export interface BlocksState {
readonly blockedByMe: ReadonlySet<number>;
readonly blockedByThem: ReadonlySet<number>;
}
const INITIAL: BlocksState = {
blockedByMe: new Set(),
blockedByThem: new Set(),
};
export const blocksStore = createStore<BlocksState>(INITIAL);
/** Replace the blocked-by-me set (from GET /blocks). */
export function setBlockedByMe(userIds: readonly number[]): void {
blocksStore.setState((prev) => ({ ...prev, blockedByMe: new Set(userIds) }));
}
/** Mark (or unmark) a recipient as having refused our DM. */
export function setUserBlockedByThem(userId: number, blocked: boolean): void {
blocksStore.setState((prev) => {
if (prev.blockedByThem.has(userId) === blocked) return prev;
const next = new Set(prev.blockedByThem);
if (blocked) next.add(userId);
else next.delete(userId);
return { ...prev, blockedByThem: next };
});
}
/** Clear all blocked-by-them state (called on ready — stale after reconnect). */
export function clearBlockedByThem(): void {
blocksStore.setState((prev) =>
prev.blockedByThem.size === 0 ? prev : { ...prev, blockedByThem: new Set() },
);
}
/**
* The composer disable reason for a DM with `recipientId`, or null if unblocked.
* blockedByMe takes precedence so the user always sees that they are the blocker.
*/
export function dmComposerBlockReason(state: BlocksState, recipientId: number): string | null {
if (state.blockedByMe.has(recipientId)) return BLOCKED_BY_ME_REASON;
if (state.blockedByThem.has(recipientId)) return BLOCKED_BY_THEM_REASON;
return null;
}
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
blocksStore,
setBlockedByMe,
setUserBlockedByThem,
clearBlockedByThem,
dmComposerBlockReason,
BLOCKED_BY_ME_REASON,
BLOCKED_BY_THEM_REASON,
} from "../../src/stores/blocks.store";
describe("blocksStore", () => {
beforeEach(() => {
blocksStore.setState(() => ({ blockedByMe: new Set(), blockedByThem: new Set() }));
});
describe("dmComposerBlockReason", () => {
it("returns null when the recipient is not blocked in either direction", () => {
expect(dmComposerBlockReason(blocksStore.getState(), 5)).toBeNull();
});
it("gates with the explicit reason when the local user blocked them", () => {
setBlockedByMe([5]);
expect(dmComposerBlockReason(blocksStore.getState(), 5)).toBe(BLOCKED_BY_ME_REASON);
expect(BLOCKED_BY_ME_REASON).toBe("You've blocked this user. Unblock to send messages.");
});
it("gates with the neutral reason when being blocked", () => {
setUserBlockedByThem(7, true);
expect(dmComposerBlockReason(blocksStore.getState(), 7)).toBe(BLOCKED_BY_THEM_REASON);
expect(BLOCKED_BY_THEM_REASON).toBe("You can't message this user right now.");
});
it("prefers the explicit reason over the neutral one when both apply", () => {
setBlockedByMe([9]);
setUserBlockedByThem(9, true);
expect(dmComposerBlockReason(blocksStore.getState(), 9)).toBe(BLOCKED_BY_ME_REASON);
});
it("only gates the blocked recipient, not others", () => {
setBlockedByMe([5]);
expect(dmComposerBlockReason(blocksStore.getState(), 6)).toBeNull();
});
});
describe("un-gating", () => {
it("un-gates when the local user unblocks (blockedByMe cleared)", () => {
setBlockedByMe([5]);
expect(dmComposerBlockReason(blocksStore.getState(), 5)).toBe(BLOCKED_BY_ME_REASON);
setBlockedByMe([]); // GET /blocks after an unblock returns the shrunken list
expect(dmComposerBlockReason(blocksStore.getState(), 5)).toBeNull();
});
it("un-gates a being-blocked recipient when cleared on reconnect", () => {
setUserBlockedByThem(7, true);
expect(dmComposerBlockReason(blocksStore.getState(), 7)).toBe(BLOCKED_BY_THEM_REASON);
clearBlockedByThem();
expect(dmComposerBlockReason(blocksStore.getState(), 7)).toBeNull();
});
it("setUserBlockedByThem(false) removes a single recipient", () => {
setUserBlockedByThem(7, true);
setUserBlockedByThem(8, true);
setUserBlockedByThem(7, false);
expect(dmComposerBlockReason(blocksStore.getState(), 7)).toBeNull();
expect(dmComposerBlockReason(blocksStore.getState(), 8)).toBe(BLOCKED_BY_THEM_REASON);
});
});
describe("immutability", () => {
it("setUserBlockedByThem is a no-op (same reference) when state is unchanged", () => {
const before = blocksStore.getState();
setUserBlockedByThem(7, false); // not present → no change
expect(blocksStore.getState()).toBe(before);
});
it("clearBlockedByThem is a no-op (same reference) when already empty", () => {
const before = blocksStore.getState();
clearBlockedByThem();
expect(blocksStore.getState()).toBe(before);
});
});
});
@@ -146,6 +146,25 @@ vi.mock("@stores/members.store", () => ({
membersStore: { getState: mockMembersStoreGetState },
}));
// Block-state gating: capture the subscription callback so tests can simulate a
// live change (block/unblock) and mock the reason the store reports.
const { mockBlocksGetState, mockDmComposerBlockReason, blocksSubscribers } = vi.hoisted(() => ({
mockBlocksGetState: vi.fn(() => ({ blockedByMe: new Set<number>(), blockedByThem: new Set() })),
mockDmComposerBlockReason: vi.fn((): string | null => null),
blocksSubscribers: [] as Array<() => void>,
}));
vi.mock("@stores/blocks.store", () => ({
blocksStore: {
getState: mockBlocksGetState,
subscribeSelector: vi.fn((_sel: unknown, cb: () => void) => {
blocksSubscribers.push(cb);
return () => {};
}),
},
dmComposerBlockReason: mockDmComposerBlockReason,
}));
// ---------------------------------------------------------------------------
// Imports
// ---------------------------------------------------------------------------
@@ -204,6 +223,8 @@ describe("createChannelController", () => {
vi.clearAllMocks();
capturedMessageListOpts = null;
capturedMessageInputOpts = null;
blocksSubscribers.length = 0;
mockDmComposerBlockReason.mockReturnValue(null);
// The controller gates sends on the store-backed connection status
// (docs/architecture/ux §3), not on ws.getState().
setConnectionStatus("connected");
@@ -844,6 +865,60 @@ describe("createChannelController", () => {
});
});
describe("DM composer block gating", () => {
function mountDm(reason: string | null): void {
mockDmStoreGetState.mockReturnValue({
channels: [
{
channelId: 42,
recipient: { id: 5, username: "alice", avatar: "", status: "online" },
lastMessageId: null,
lastMessage: "",
lastMessageAt: "",
unreadCount: 0,
},
],
});
mockDmComposerBlockReason.mockReturnValue(reason);
const ctrl = createChannelController(makeOpts());
ctrl.mountChannel(42, "alice", "dm");
}
it("disables the DM composer with the explicit reason when the user blocked them", () => {
mountDm("You've blocked this user. Unblock to send messages.");
expect(mockSetDisabled).toHaveBeenLastCalledWith(
"You've blocked this user. Unblock to send messages.",
);
});
it("disables the DM composer with the neutral reason when being blocked", () => {
mountDm("You can't message this user right now.");
expect(mockSetDisabled).toHaveBeenLastCalledWith("You can't message this user right now.");
});
it("leaves the DM composer enabled when not blocked", () => {
mountDm(null);
expect(mockSetDisabled).toHaveBeenLastCalledWith(null);
});
it("un-gates live when the block clears (unblock)", () => {
mountDm("You've blocked this user. Unblock to send messages.");
expect(mockSetDisabled).toHaveBeenLastCalledWith(
"You've blocked this user. Unblock to send messages.",
);
// Simulate an unblock: the store reports no reason and notifies subscribers.
mockDmComposerBlockReason.mockReturnValue(null);
for (const cb of blocksSubscribers) cb();
expect(mockSetDisabled).toHaveBeenLastCalledWith(null);
});
it("does not subscribe to block state for non-DM channels", () => {
const ctrl = createChannelController(makeOpts());
ctrl.mountChannel(42, "general", "text");
expect(blocksSubscribers).toHaveLength(0);
});
});
describe("destroyChannel edge cases", () => {
it("destroyChannel is safe to call when no channel is mounted", () => {
const opts = makeOpts();
@@ -11,6 +11,7 @@ import {
import { membersStore } from "../../src/stores/members.store";
import { voiceStore } from "../../src/stores/voice.store";
import { dmStore } from "../../src/stores/dm.store";
import { blocksStore } from "../../src/stores/blocks.store";
import { uiStore } from "../../src/stores/ui.store";
import type { WsClient, WsListener } from "../../src/lib/ws";
import type { ServerMessage } from "../../src/lib/types";
@@ -133,6 +134,7 @@ describe("WS Dispatcher", () => {
voiceStatus: "idle",
}));
dmStore.setState(() => ({ channels: [] }));
blocksStore.setState(() => ({ blockedByMe: new Set(), blockedByThem: new Set() }));
uiStore.setState((prev) => ({ ...prev, transientError: null }));
mock = createMockWs();
@@ -835,6 +837,68 @@ describe("WS Dispatcher", () => {
expect(uiStore.getState().transientError).toBeNull();
});
it("gates the DM composer (blockedByThem) when a DM send is refused with FORBIDDEN", () => {
dmStore.setState(() => ({
channels: [
{
channelId: 7,
recipient: { id: 5, username: "alice", avatar: "", status: "online" },
lastMessageId: null,
lastMessage: "",
lastMessageAt: "",
unreadCount: 0,
},
],
}));
addOptimisticMessage({
correlationId: "corr-dm",
channelId: 7,
user: { id: 1, username: "alex", avatar: null },
content: "hi",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
mock.dispatch("error", { code: "FORBIDDEN", message: "blocked" }, "corr-dm");
expect(blocksStore.getState().blockedByThem.has(5)).toBe(true);
// Still marks the row failed (existing behaviour preserved).
expect(getChannelMessages(7)[0]!.status).toBe("failed");
});
it("does not gate on a FORBIDDEN send outside a DM channel", () => {
// channel 7 is not in dmStore → no block state is inferred.
addOptimisticMessage({
correlationId: "corr-nondm",
channelId: 7,
user: { id: 1, username: "alex", avatar: null },
content: "hi",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
mock.dispatch("error", { code: "FORBIDDEN", message: "nope" }, "corr-nondm");
expect(blocksStore.getState().blockedByThem.size).toBe(0);
});
it("on ready clears being-blocked state and refreshes blocked-by-me via api", async () => {
cleanup(); // tear down the no-api dispatcher wired in beforeEach
const listBlocks = vi.fn().mockResolvedValue({ blocked_user_ids: [11, 22] });
cleanup = wireDispatcher(mock.ws, { listBlocks });
// Pre-seed a stale being-blocked entry that a fresh ready must clear.
blocksStore.setState(() => ({ blockedByMe: new Set(), blockedByThem: new Set([5]) }));
mock.dispatch("ready", { channels: [], members: [], voice_states: [], roles: [] });
expect(blocksStore.getState().blockedByThem.size).toBe(0);
expect(listBlocks).toHaveBeenCalled();
await Promise.resolve();
await Promise.resolve();
expect([...blocksStore.getState().blockedByMe]).toEqual([11, 22]);
});
it("wires a local transport send failure to mark the pending row failed", () => {
uiStore.setState((prev) => ({ ...prev, transientError: null }));
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const { mockCheckForUpdate, mockDownloadAndInstall } = vi.hoisted(() => ({
mockCheckForUpdate: vi.fn(),
mockDownloadAndInstall: vi.fn(),
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}));
vi.mock("@lib/updater", () => ({
checkForUpdate: mockCheckForUpdate,
downloadAndInstallUpdate: mockDownloadAndInstall,
}));
import { createUpdateNotifier, formatDownloadProgress } from "../../src/components/UpdateNotifier";
import type { DownloadProgress } from "../../src/lib/updater";
// ---------------------------------------------------------------------------
// Pure formatter
// ---------------------------------------------------------------------------
describe("formatDownloadProgress", () => {
it("shows a percentage when the total size is known", () => {
expect(formatDownloadProgress({ received: 50, total: 100 })).toBe("Downloading update… 50%");
});
it("clamps the percentage to 0..100", () => {
expect(formatDownloadProgress({ received: 250, total: 100 })).toBe("Downloading update… 100%");
});
it("falls back to bytes (MB) when the total is unknown", () => {
expect(formatDownloadProgress({ received: 5 * 1024 * 1024, total: null })).toBe(
"Downloading update… 5.0 MB",
);
});
});
// ---------------------------------------------------------------------------
// Banner rendering
// ---------------------------------------------------------------------------
describe("createUpdateNotifier download progress", () => {
let host: HTMLElement;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
host = document.createElement("div");
document.body.appendChild(host);
});
afterEach(() => {
vi.useRealTimers();
host.remove();
});
async function mountWithAvailableUpdate(): Promise<void> {
mockCheckForUpdate.mockResolvedValue({ available: true, version: "1.2.0", body: "" });
const notifier = createUpdateNotifier({ serverUrl: "https://s.example" });
notifier.mount(host);
await vi.advanceTimersByTimeAsync(3000); // fire the delayed check + resolve
}
function bannerText(): string | null | undefined {
return host.querySelector(".update-banner-text")?.textContent;
}
it("updates the banner from the updater progress callback", async () => {
let onProgress: ((p: DownloadProgress) => void) | undefined;
mockDownloadAndInstall.mockImplementation((_url: string, cb: (p: DownloadProgress) => void) => {
onProgress = cb;
return new Promise<void>(() => {}); // never resolves — stays "downloading"
});
await mountWithAvailableUpdate();
(host.querySelector(".update-banner-install") as HTMLButtonElement).click();
// Initial state before any progress event.
expect(bannerText()).toBe("Downloading update…");
expect(mockDownloadAndInstall).toHaveBeenCalledWith("https://s.example", expect.any(Function));
onProgress!({ received: 25, total: 100 });
expect(bannerText()).toBe("Downloading update… 25%");
onProgress!({ received: 2 * 1024 * 1024, total: null });
expect(bannerText()).toBe("Downloading update… 2.0 MB");
});
it("shows a failure message when the download rejects", async () => {
mockDownloadAndInstall.mockRejectedValue(new Error("boom"));
await mountWithAvailableUpdate();
(host.querySelector(".update-banner-install") as HTMLButtonElement).click();
// Let the rejected install promise settle and the catch run.
await Promise.resolve();
await Promise.resolve();
expect(bannerText()).toBe("Update failed. Please try again later.");
});
});
+16 -6
View File
@@ -144,12 +144,22 @@ and `IsEitherBlocked` is bidirectional). **Target UX:**
| Being blocked | Composer read-only with a neutral "You can't message this user right now." (do not reveal the block state explicitly — the server returns a generic refusal) |
| Unblock | Composer re-enables |
> **⚠ Current gap.** There is no client-side block-state composer gating. The
> composer now has a disabled-with-reason mode (see [messaging.md §2](messaging.md)),
> but DM channels are left ungated: the block/unblock REST surface exists
> server-side, and the client refuses a DM send only via the failed-row /
> `FORBIDDEN` path today. Target ties DM block state into the same
> composer-state machine.
> **✅ Wired (composer gating).** DM block state now drives the same
> disabled-with-reason composer mode (see [messaging.md §2](messaging.md)) via
> `blocks.store`. `blockedByMe` is loaded authoritatively from `GET /blocks` on
> every `ready` (`dispatcher.ts`) → the explicit "You've blocked this user…"
> reason. `blockedByThem` is inferred from a refused DM send (`ErrBlocked` →
> `FORBIDDEN`, bidirectional) → the neutral "You can't message this user right
> now." reason, and is cleared on the next `ready` so a reconnect re-evaluates.
> `ChannelController` reads `dmComposerBlockReason(recipientId)` and subscribes to
> `blocks.store`, so an unblock (shrunken `GET /blocks`) re-enables the composer
> live. `blockedByMe` takes precedence when both directions apply.
>
> **Remaining gap.** There is no in-client **block button** yet (the block/unblock
> REST surface exists server-side; blocks made from the web panel or a prior
> session are honoured via `GET /blocks`). Adding the block affordance to the DM
> profile sidebar would call `PUT/DELETE /blocks/{userId}` and update `blocks.store`
> directly for an instant local un-gate.
---
+8 -5
View File
@@ -171,14 +171,17 @@ sequenceDiagram
|-------|--------------|
| checking | Silent (no UI until a result) |
| available | Non-modal banner with version + Update Now / Later (already `UpdateNotifier.ts:30-62`) |
| downloading | Banner "Downloading update…" |
| downloading | Banner "Downloading update… N%" (or "… N.N MB" until Content-Length is known) |
| applied | App relaunches automatically |
| failed | "Update failed. Please try again later." + Dismiss |
> **⚠ Current gap — no download progress.** The download callback is a no-op
> (`update_commands.rs:177`), so "Downloading update…" has no percentage. For a
> large binary this looks hung. Target: surface a progress indicator (percentage
> or indeterminate-with-bytes) by wiring the plugin's progress callback.
> **✅ Wired — download progress.** The Rust download callback
> (`download_and_install_update` in `update_commands.rs`) accumulates received
> bytes and emits an `update-progress` event (`{ received, total }`) to the
> webview. `downloadAndInstallUpdate(serverUrl, onProgress)` (`updater.ts`) listens
> for it and forwards to `UpdateNotifier`, whose `formatDownloadProgress` renders a
> percentage when `total` is known and falls back to bytes (MB) otherwise, so the
> banner never looks hung. (Rust change is minimal and CI-gated only.)
---