mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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) });
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user