Files
OwnCord/Client/tauri-client/src/pages/MainPage.ts
T
J3vbandClaude Opus 5 ea0430c5b0 fix: batch of correctness fixes across server and client (#1375)
* fix(client): 1 defect(s) (OC-0201)

* fix(service): 1 defect(s) (OC-0202)

HandleTyping built the per-user-per-channel rate-limit key before resolving the channel or checking read permission, so forged channel ids could pin unbounded dead entries in the shared process-wide RateLimiter.

* fix(client): 2 defect(s) (OC-0203, OC-0224)

* fix(server): 1 defect(s) (OC-0204)

* fix(ws): 2 defect(s) (OC-0205, OC-0211)

* fix(admin): 2 defect(s) (OC-0209, OC-0212)

* fix(client): 1 defect(s) (OC-0210)

* fix(db): 1 defect(s) (OC-0213)

* fix(ws): 1 defect(s) (OC-0214)

Route handler-driven PresenceEvent through BroadcastToAll instead of BroadcastToAllLow so every source of a user's presence shares one ordered per-client FIFO.

* fix(admin): 1 defect(s) (OC-0215)

PATCH /users/{id} combining banned + role_id committed and broadcast the ban before authorizing the role change, so a refused role change returned an error while leaving the target banned. Authorize the role change up front via the new ModerationService.AuthorizeRoleChange.

* fix(db): 1 defect(s) (OC-0216)

LinkAttachmentsToMessage no longer claims an attachment that is a user's live avatar (users.avatar points at it). Once message_id is set, handleServeFile's avatar branch (gated on ChannelID == nil) is unreachable and the file falls under the message's channel ACL / soft-delete state, permanently disagreeing with users.avatar about who may read it.

* fix(emoji): 1 defect(s) (OC-0217)

* fix(client): 1 defect(s) (OC-0218)

The data-copy phase of an HTTP proxy tunnel was unbounded. Steps 1-2 of
handle_connection (header read, TCP connect, TLS handshake) each run under
a 10s guard, but step 3 called io::copy_bidirectional with no deadline. A
remote that completes the TLS handshake and then neither responds nor
closes parks the spawned connection task, the loopback socket and the
remote TLS session indefinitely: copy_bidirectional only resolves once
BOTH directions finish, so closing the local side alone does not free it.

Wrap the copy in copy_with_deadline, a generic helper bounded by
DATA_PHASE_TIMEOUT (600s). The bound is deliberately far looser than the
10s setup guards because this phase carries the REST body, including
attachment and avatar uploads, so it must reclaim only genuinely stuck
connections rather than merely slow ones. The helper is generic over the
stream types so it can be exercised without a live TLS connection.

Regression test drives two in-memory duplex pairs whose far ends stay
alive, so neither half ever observes EOF and raw copy_bidirectional would
block forever; the test asserts the call resolves on its own deadline with
ErrorKind::TimedOut.

Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* fix(ws): 1 defect(s) (OC-0219)

* fix(client): 1 defect(s) (OC-0221)

UpdateNotifier scheduled its deferred update check with a setTimeout whose
handle was never retained, so destroy() could not cancel it. A component torn
down inside the 3s window (page swap / logout) still fired performCheck() and
issued a network update check against the old server URL. Retain the timer
handle and clear it in destroy().

* fix(dm): 1 defect(s) (OC-0222)

* fix(client): 1 defect(s) (OC-0223)

* fix(voice): 1 defect(s) (OC-0225)

The Grant-Microphone retry's .finally hardcoded grantMicBtn.disabled = false, undoing updateFrozen()'s socket-down freeze when the WS socket dropped while the mic permission request was in flight. Delegate the state back to render().

* fix(admin): 1 defect(s) (OC-0226)

handleApplyUpdate broadcasts a 'restarting in 5s' notice before the on-disk
swap. Every failure path in the swap returned silently, leaving clients
counting down to a restart that never happened. Extract the swap into
applyStagedUpdate and send a corrective 'update_aborted' broadcast from a
deferred guard on every path that does not reach the respawn.

* fix(admin): 1 defect(s) (OC-0227)

PATCH /channels/{id} accepted a blank or whitespace-only name, leaving the
channel unidentifiable in clients. updateChannelRequest.validate() now
rejects it the way handleCreateChannel already did.

* fix(identity): 1 defect(s) (OC-0228)

* fix(admin): run deferred cleanup before the update restart exits

The fix batch left three golangci-lint findings and two prettier findings
that CI gates on.

applyStagedUpdate called os.Exit(0) in the same function that defers both
staged.Close() and the corrective "update_aborted" broadcast, so neither
ran (gocritic exitAfterDefer). Return a bool instead and let the caller
exit once those defers have run — on Windows, releasing the staged binary's
file handle is the reason the restart exists at all, so this is a real fix
rather than a lint appeasement. The exported test hook calls the function as
a statement, so the added result does not affect it.

Also modernize a bulk-insert loop to range-over-int, compare backup bytes
with bytes.Equal, and reflow two test files to prettier's output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* test(ws): pin the live presence path against the invisible custom-status leak

OC-0207 and OC-0211 are the same defect at two emitters: hub_broadcast.go's
BroadcastPresence (connect/reconnect) and event.go's presenceEvents (live
presence_update). The fix for OC-0211 closed both sites in one change, but
only the hub_broadcast side got a regression test.

This pins the event.go sibling: an invisible user's real custom status must
be blanked on the PresenceOthersEvent frame while the owner's own
PresenceSelfEvent still carries it. Without it, a later change could reopen
the live path while the committed test kept passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* fix(ws): 1 defect(s) (OC-0206)

* test(ws): silence a contextcheck false positive in the reconnect race test

RefreshChannelVisibility takes no context by design — it is reached through
the admin HubBroadcaster interface, which carries none, so it builds its own
internally. contextcheck flags the call only because the test closure around
it holds a ctx for its override write, so there is nothing to propagate.
Suppress at the call site rather than widen a production interface (and its
mocks) to satisfy a lint in a test.

golangci-lint v2.11.3 (the version ci.yml pins) now reports 0 issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 16:30:05 +02:00

852 lines
32 KiB
TypeScript

// MainPage — primary app layout after login.
// Composes standalone components; never sets innerHTML with user content.
// Delegates sidebar and chat-area DOM construction to sub-orchestrators.
import { createElement, appendChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import type { WsClient } from "@lib/ws";
import type { UserStatus } from "@lib/types";
import type { ApiClient } from "@lib/api";
import { createLogger } from "@lib/logger";
import { createRateLimiterSet } from "@lib/rate-limiter";
import type { VideoGridComponent } from "@components/VideoGrid";
import { createServerBanner, applyConnectionStatus } from "@components/ServerBanner";
import type { ServerBannerControl } from "@components/ServerBanner";
import { createSettingsOverlay } from "@components/SettingsOverlay";
import { createToastContainer } from "@components/Toast";
import type { ToastContainer } from "@components/Toast";
import { initToast, teardownToast, showToast } from "@lib/toast";
import { logout } from "@lib/logout";
import { authStore, clearAuth, updateUser } from "@stores/auth.store";
import { closeSettings, uiStore } from "@stores/ui.store";
import { loadUserStatus } from "@lib/userStatus";
import { createPresenceSender } from "@lib/presence";
import { startAutoIdle, type AutoIdleController } from "@lib/autoIdle";
import { channelsStore, getActiveChannel } from "@stores/channels.store";
import { dmStore, dmDisplayName } from "@stores/dm.store";
import { voiceStore } from "@stores/voice.store";
import { clearCustomEmoji } from "@stores/emoji.store";
import {
cleanupAll as voiceCleanupAll,
setOnRemoteVideo,
setOnRemoteVideoRemoved,
clearOnRemoteVideo,
setWsClient,
setServerHost as setLiveKitServerHost,
setOnError as setVoiceOnError,
} from "@lib/livekitSession";
import { setServerHost } from "@components/message-list/renderers";
import { clearAttachmentCaches } from "@components/message-list/attachments";
import { closeActiveLightbox } from "@components/message-list/media";
import {
setReactionUsersFetcher,
clearReactionUsersCache,
} from "@components/message-list/reaction-tooltip";
import { setMarkReadSender } from "@lib/read-state";
import { setChannelMutesHost } from "@lib/channel-mutes";
import { setNsfwGateHost } from "@lib/nsfw-gate";
import { setAudioVolumeHost } from "@lib/audioElements";
import { createQuickSwitcherManager } from "./main-page/OverlayManagers";
import { attachGlobalKeybinds } from "./main-page/GlobalKeybinds";
import { createVoiceWidgetCallbacks } from "./main-page/VoiceCallbacks";
import { createMessageController, createPendingDeleteManager } from "./main-page/MessageController";
import type { MessageController } from "./main-page/MessageController";
import { createReactionController } from "./main-page/ReactionController";
import type { ReactionController } from "./main-page/ReactionController";
import { createVideoModeController } from "./main-page/VideoModeController";
import type { VideoModeController } from "./main-page/VideoModeController";
import { createChannelController } from "./main-page/ChannelController";
import type { ChannelController } from "./main-page/ChannelController";
import { createUpdateNotifier } from "@components/UpdateNotifier";
import { createDmProfileSidebar } from "@components/DmProfileSidebar";
import type { DmProfileSidebarComponent } from "@components/DmProfileSidebar";
import { createIncomingCallBanner } from "@components/IncomingCallBanner";
import type { IncomingCallBannerComponent } from "@components/IncomingCallBanner";
import { createRingController } from "@lib/call-ring";
import type { RingController } from "@lib/call-ring";
import { startRingChime, stopRingChime } from "@lib/notifications";
import { createSidebarVoiceCallbacks } from "./main-page/VoiceCallbacks";
import { createSidebarArea } from "./main-page/SidebarArea";
import { createChatArea } from "./main-page/ChatArea";
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
const log = createLogger("main-page");
// ---------------------------------------------------------------------------
// Options
// ---------------------------------------------------------------------------
export interface MainPageOptions {
readonly ws: WsClient;
readonly api: ApiClient;
}
// ---------------------------------------------------------------------------
// MainPage
// ---------------------------------------------------------------------------
export function createMainPage(options: MainPageOptions): MountableComponent {
const { ws, api } = options;
// Let voiceSession send signaling messages over this WS connection
setWsClient(ws);
// Set server host for resolving relative attachment URLs and LiveKit proxy
const apiConfig = api.getConfig();
if (apiConfig.host) {
setServerHost(apiConfig.host);
setLiveKitServerHost(apiConfig.host);
}
// Channel ids are only unique per server, so anything persisted under a bare
// channel id collides across profiles in the multi-server client. Scope both
// stores to the connected host — including the null case, so a disconnect
// cannot leave the previous server's scope armed for the next connection.
setChannelMutesHost(apiConfig.host ?? null);
setNsfwGateHost(apiConfig.host ?? null);
setAudioVolumeHost(apiConfig.host ?? null);
// "Mark as Read" affordances need the socket but are reached from deep inside
// the sidebar; register the sender once instead of threading ws through.
setMarkReadSender((channelId) => {
ws.send({ type: "mark_read", payload: { channel_id: channelId } });
});
// The who-reacted tooltip fetches on hover; give it the live REST client the
// same way the attachment renderer is given the server host.
clearReactionUsersCache();
setReactionUsersFetcher(async (channelId, messageId, emoji) => {
const res = await api.getReactionUsers(channelId, messageId, emoji);
return res.users;
});
const limiters = createRateLimiterSet();
// The one presence_update sender for this session — owns the limiter
// token, the drop-window retry, and the local optimistic update, so
// every producer (auto-idle, the settings Account tab via applyPresence
// below, and the UserBar status picker it's threaded to through
// SidebarArea) shares the exact same budget the server enforces (1
// update / 10s, keyed by user id — service/channel.go). A limiter created
// per producer instead cannot predict that shared, cross-surface budget
// (OC-0210).
const presenceSender = createPresenceSender(ws, limiters.presence);
let container: Element | null = null;
let root: HTMLDivElement | null = null;
// Child components tracked for cleanup
let children: MountableComponent[] = [];
let unsubscribers: Array<() => void> = [];
// Refs we need to update reactively
let banner: ServerBannerControl | null = null;
// Video grid (owned by ChatArea, referenced for remote video wiring)
let videoGrid: VideoGridComponent | null = null;
// Pending delete confirmations (double-click to delete pattern)
const pendingDeleteManager = createPendingDeleteManager();
// Extracted controllers (created in mount)
let msgCtrl: MessageController | null = null;
let reactionCtrl: ReactionController | null = null;
let videoModeCtrl: VideoModeController | null = null;
let channelCtrl: ChannelController | null = null;
/** Inactivity watcher that flips the status to idle after ten quiet
* minutes. Started once the socket is up, torn down with the page. */
let autoIdle: AutoIdleController | null = null;
// Toast container for user-facing error feedback
let toast: ToastContainer | null = null;
// DM profile sidebar (right panel, toggled via DM header click)
let dmProfileSidebar: DmProfileSidebarComponent | null = null;
let dmProfileSlot: HTMLDivElement | null = null;
// DM calls: the banner draws a ring, the controller owns its lifetime.
let callBanner: IncomingCallBannerComponent | null = null;
let ringCtrl: RingController | null = null;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getCurrentUserId(): number {
return authStore.getState().user?.id ?? 0;
}
/**
* Re-assert the status the user picked, if the server disagrees.
*
* This used to fire on every connect, because the server stamped everyone
* online at handshake and the client had to race to correct it — which is
* what made a chosen Do Not Disturb (and "appear offline") flash online on
* every reconnect. The server now reads the saved status and announces
* *that*, so this is a no-op in the normal case and only speaks up when the
* two genuinely differ (an older server, or a status changed while the
* socket was down).
*/
function restoreSavedPresence(): void {
const status = loadUserStatus();
const serverStatus = authStore.getState().user?.status;
if (serverStatus === status) return;
applyPresence(status);
}
/** Send a presence change and reflect it locally. Shared by the settings
* tab, the user bar and the auto-idle timer so all three agree.
*
* The presence limiter is 1 token per 10s, and auto-idle's return-to-
* online fires unthrottled milliseconds after its own idle transition
* (autoIdle.ts) — routinely losing the token race. Dropping that frame
* silently would leave the server, and everyone else's member list,
* stuck on "idle" with nothing left to correct it. `presenceSender`
* retries once the window reopens instead, re-reading the status at that
* time so a burst of calls in between coalesces onto one retry carrying
* the latest value — see @lib/presence. */
function applyPresence(status: UserStatus): void {
presenceSender.send(status);
}
/**
* Resolve a channel's display name. A DM is named by who is in it (or, for a
* group, by its name), and the store is the authority on that — the channels
* store carries a synthesised copy that can lag a rename or a departure.
*/
function resolveChannelName(
channelId: number,
channelName: string,
channelType?: string,
): string {
if (channelType === "dm") {
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
if (dm !== undefined) return dmDisplayName(dm);
}
return channelName;
}
/** Toggle the DM profile sidebar open/closed for the current DM partner. */
function toggleDmProfile(): void {
if (dmProfileSlot === null) return;
// If already open, close it
if (dmProfileSidebar !== null) {
dmProfileSidebar.destroy?.();
dmProfileSidebar = null;
return;
}
// Only open in DM mode
const active = getActiveChannel();
if (active === null || active.type !== "dm") return;
const dmChannel = dmStore.getState().channels.find((c) => c.channelId === active.id);
// A group has no single "recipient" — dm.store.ts documents .recipient as
// just the first of .participants for a group, with group-correct code
// expected to read .participants instead. A 1:1 profile panel built from
// it would present one arbitrary member's identity as the conversation.
if (dmChannel === undefined || dmChannel.isGroup) return;
const recipient = dmChannel.recipient;
const status =
recipient.status === "online" ||
recipient.status === "idle" ||
recipient.status === "dnd" ||
recipient.status === "offline"
? recipient.status
: ("offline" as const);
dmProfileSidebar = createDmProfileSidebar({
user: {
id: recipient.id,
username: recipient.username,
avatar: recipient.avatar || null,
status,
about: null,
joinDate: null,
},
host: apiConfig.host ?? "",
onClose: () => {
dmProfileSidebar?.destroy?.();
dmProfileSidebar = null;
},
});
dmProfileSidebar.mount(dmProfileSlot);
}
/**
* Start a call in the currently open DM: join its voice channel and ring the
* other participants.
*
* Joining first is deliberate. A "call" is presence in the DM's voice
* channel, so the ring is only truthful once the caller is actually there —
* ringing first would offer an empty room to whoever accepts.
*/
function startCall(): void {
const active = getActiveChannel();
if (active === null || active.type !== "dm") return;
// onVoiceJoin silently refuses to join when the socket is down
// (VoiceCallbacks.ts's socketLive() guard) — without this check the ring
// and "Calling…" toast fire anyway, promising a call nobody can hear.
if (uiStore.getState().connectionStatus !== "connected") {
showToast("Not connected", "error");
return;
}
createSidebarVoiceCallbacks(ws).onVoiceJoin(active.id);
ws.send({ type: "call_ring", payload: { channel_id: active.id } });
showToast("Calling…", "info");
}
/** Close the DM profile sidebar if open. */
function closeDmProfile(): void {
if (dmProfileSidebar !== null) {
dmProfileSidebar.destroy?.();
dmProfileSidebar = null;
}
}
// ---------------------------------------------------------------------------
// Mount / Destroy
// ---------------------------------------------------------------------------
function mount(target: Element): void {
log.info("MainPage mounting");
container = target;
root = createElement("div", {
style: "display:flex;flex-direction:column;height:100vh;width:100%",
});
// --- Reconnect banner ---
banner = createServerBanner();
root.appendChild(banner.element);
// Banner reacts to the store-backed connection status (single source of
// truth, docs/architecture/ux §3). "disconnected" keeps the banner visible
// — a fatal drop navigates away via clearAuth, and anything short of that
// must not leave a stale "Reconnecting..." on screen.
unsubscribers.push(
uiStore.subscribeSelector(
(s) => s.connectionStatus,
(status) => {
try {
if (status === "connected") restoreSavedPresence();
if (banner === null) return;
applyConnectionStatus(banner, status);
} catch (err) {
log.error("Connection status handler error", err);
}
},
),
);
// Synchronous initial sync: the selector subscription baselines on the
// current value and only fires on change, so a MainPage mounted mid-outage
// (status already "reconnecting") would otherwise never show the banner —
// the whole retry cycle maps to the same 3-state value.
applyConnectionStatus(banner, uiStore.getState().connectionStatus);
if (uiStore.getState().connectionStatus === "connected") restoreSavedPresence();
// Auto-idle. It only ever moves a status it is itself responsible for
// (see @lib/autoIdle) — a manually chosen Idle, Do Not Disturb or
// Invisible is never touched — so it is safe to leave running for the
// whole session.
autoIdle = startAutoIdle({ onStatusChange: (status) => applyPresence(status) });
unsubscribers.push(
ws.on("server_restart", (payload) => {
try {
// A "shutdown" broadcast kicks back to the login screen (handled in
// the dispatcher) — no point starting a countdown on a page that is
// about to unmount.
if (banner !== null && payload.reason !== "shutdown") {
banner.showRestart(payload.delay_seconds);
}
} catch (err) {
log.error("Server restart handler error", err);
}
}),
);
// --- Main .app row ---
const app = createElement("div", { class: "app", "data-testid": "app-layout" });
// --- Sidebar (server strip + channel sidebar + voice widget + user bar) ---
const sidebar = createSidebarArea({
ws,
api,
limiters,
presenceSender,
getRoot: () => root,
getToast: () => toast,
onWatchStream: (userId) => {
if (videoModeCtrl === null) return;
videoModeCtrl.showVideoGrid();
videoModeCtrl.setFocus(userId);
},
});
children.push(...sidebar.children);
unsubscribers.push(...sidebar.unsubscribers);
// --- Chat area ---
const chatAreaResult = createChatArea({
api,
getRoot: () => root,
getToast: () => toast,
getChannelCtrl: () => channelCtrl,
onToggleDmProfile: () => {
toggleDmProfile();
},
onStartCall: () => {
startCall();
},
});
dmProfileSlot = chatAreaResult.dmProfileSlot;
children.push(...chatAreaResult.children);
unsubscribers.push(...chatAreaResult.unsubscribers);
videoGrid = chatAreaResult.videoGrid;
// Video mode controller (chat/video toggle + tile management)
videoModeCtrl = createVideoModeController({
slots: chatAreaResult.slots,
videoGrid: chatAreaResult.videoGrid,
getCurrentUserId,
});
appendChildren(
app,
sidebar.sidebarWrapper,
chatAreaResult.chatArea,
chatAreaResult.dmProfileSlot,
);
root.appendChild(app);
// Settings overlay
const settingsOverlay = createSettingsOverlay({
onClose: () => closeSettings(),
onChangePassword: async (oldPassword, newPassword) => {
try {
await api.changePassword(oldPassword, newPassword);
showToast("Password changed successfully", "success");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to change password";
showToast(msg, "error");
throw err;
}
},
onUpdateProfile: async (patch) => {
try {
// The username is required by the API but optional in the patch (the
// profile form only edits the display name and about), so fill it in
// from the current user rather than making every caller repeat it.
const username = patch.username ?? authStore.getState().user?.username ?? "";
const updated = await api.updateProfile({ ...patch, username });
updateUser({
username: updated.username,
display_name: updated.display_name ?? null,
about: updated.about ?? null,
});
showToast("Profile updated", "success");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to update profile";
showToast(msg, "error");
throw err;
}
},
onUploadAvatar: async (file) => {
try {
const uploaded = await api.uploadAvatar(file);
// The server has already pointed the column at the served file and
// broadcast a user_update; this keeps the local copy from lagging a
// round-trip behind.
updateUser({ avatar: uploaded.url });
showToast("Avatar updated", "success");
return uploaded.url;
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to upload avatar";
showToast(msg, "error");
throw err;
}
},
onLogout: () => logout(api),
onDeleteAccount: async (password) => {
await api.deleteAccount(password);
clearAuth();
showToast("Account deleted successfully", "success");
},
onEnableTotp: async (password) => {
try {
return await api.enableTotp(password);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to enable 2FA";
showToast(msg, "error");
throw err;
}
},
onConfirmTotp: async (password, code) => {
try {
await api.confirmTotp(password, code);
updateUser({ totp_enabled: true });
showToast("Two-factor authentication enabled", "success");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to confirm 2FA";
showToast(msg, "error");
throw err;
}
},
onDisableTotp: async (password) => {
try {
await api.disableTotp(password);
updateUser({ totp_enabled: false });
showToast("Two-factor authentication disabled", "success");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to disable 2FA";
showToast(msg, "error");
throw err;
}
},
onStatusChange: (status) => applyPresence(status),
});
settingsOverlay.mount(root);
children.push(settingsOverlay);
// Quick switcher (Ctrl+K)
// Don't fire while the settings panel is on top of it — same guard as
// attachGlobalKeybinds below, reading the same source of truth.
const qsManager = createQuickSwitcherManager(
() => root,
() => uiStore.getState().settingsOpen,
);
unsubscribers.push(qsManager.attach());
// The rest of the shortcuts listed on the settings Keybinds tab.
const voiceKeybindActions = createVoiceWidgetCallbacks(ws, limiters);
unsubscribers.push(
attachGlobalKeybinds({
onSearch: () => chatAreaResult.searchCtrl.open(),
onToggleMute: () => voiceKeybindActions.onMuteToggle(),
onToggleDeafen: () => voiceKeybindActions.onDeafenToggle(),
onToggleCamera: () => voiceKeybindActions.onCameraToggle(),
onUploadFile: () => channelCtrl?.openFilePicker(),
// Don't fire app shortcuts while the settings panel is on top of them.
isSuspended: () => uiStore.getState().settingsOpen,
}),
);
// Toast container
toast = createToastContainer();
toast.mount(root);
children.push(toast);
initToast(toast);
// --- DM calls ---
// The banner is mounted on the page root rather than inside the chat area
// so a ring stays visible while the user is looking at another channel —
// which is exactly when a call most needs to be answerable.
ringCtrl = createRingController({
onRingStateChange: (state) => callBanner?.setRing(state),
onChime: (playing) => (playing ? startRingChime() : stopRingChime()),
onAccept: (channelId) => {
createSidebarVoiceCallbacks(ws).onVoiceJoin(channelId);
},
onDecline: (channelId) => {
ws.send({ type: "call_decline", payload: { channel_id: channelId } });
},
});
callBanner = createIncomingCallBanner({
onAccept: () => {
// ringCtrl.accept() unconditionally consumes the ring
// (stopRinging) before onVoiceJoin ever runs, and onVoiceJoin itself
// silently refuses to join while the socket is down (VoiceCallbacks
// .ts's socketLive() guard) — so accepting while reconnecting would
// otherwise discard the ring for good with no join and no retry.
// Guarded here, the banner's only caller of accept(), so the ring
// survives for the user to accept again once reconnected.
if (uiStore.getState().connectionStatus !== "connected") {
showToast("Can't answer while reconnecting", "error");
return;
}
ringCtrl?.accept();
},
onDecline: () => ringCtrl?.decline(),
});
callBanner.mount(root);
children.push(callBanner);
unsubscribers.push(
ws.on("call_incoming", (payload) => {
try {
// A call in the DM you are already sitting in still rings: the
// channel being open does not mean the app has focus, and Discord
// rings there too.
ringCtrl?.incoming({
channelId: payload.channel_id,
fromUserId: payload.from_user,
fromUsername: payload.username,
});
} catch (err) {
log.error("call_incoming handler error", err);
}
}),
);
unsubscribers.push(
ws.on("call_declined", (payload) => {
// Addressed to every other DM participant, not just the caller — the
// server holds no call state to target with (see handlers_call.go).
// In a group DM that includes fellow callees who are also ringing;
// only the actual ringer declining should silence this client's ring.
const ringing = ringCtrl?.current();
if (ringing === null || ringing === undefined) return;
if (payload.from_user === ringing.fromUserId) {
ringCtrl?.cancel(payload.channel_id);
}
}),
);
// The ringer hanging up before anyone answered: their voice_leave is the
// only signal there is that the call is over, because there is no call
// record to close. Ringing for a room with nobody in it is worse than a
// missed call, so a leave stops the ring for that channel.
unsubscribers.push(
ws.on("voice_leave", (payload) => {
const ringing = ringCtrl?.current();
if (ringing === null || ringing === undefined) return;
if (payload.user_id === ringing.fromUserId) {
ringCtrl?.cancel(payload.channel_id);
}
}),
);
unsubscribers.push(() => {
ringCtrl?.destroy();
ringCtrl = null;
callBanner = null;
});
// Message loading controller
msgCtrl = createMessageController({
api,
showError: (msg) => showToast(msg, "error"),
});
// Reaction controller
reactionCtrl = createReactionController({
ws,
reactionsLimiter: limiters.reactions,
getChannelId: () => channelCtrl?.currentChannelId ?? 0,
showError: (msg) => showToast(msg, "error"),
});
// Channel controller (mount/destroy MessageList, TypingIndicator, MessageInput per channel)
channelCtrl = createChannelController({
ws,
api,
msgCtrl: msgCtrl,
pendingDeleteManager,
reactionCtrl: reactionCtrl,
typingLimiter: limiters.typing,
showToast: (msg, type) => showToast(msg, type as "success" | "error" | "info"),
getCurrentUserId,
slots: {
messagesSlot: chatAreaResult.slots.messagesSlot,
typingSlot: chatAreaResult.slots.typingSlot,
inputSlot: chatAreaResult.slots.inputSlot,
},
chatHeaderName: chatAreaResult.chatHeaderName,
chatHeaderRefs: chatAreaResult.chatHeaderRefs,
});
// Wire voice error callback to toast
setVoiceOnError((msg) => showToast(msg, "error"));
// Wire remote video callbacks to video grid
setOnRemoteVideo((userId, stream, isScreenshare) => {
if (videoGrid === null) return;
const voice = voiceStore.getState();
const channelId = voice.currentChannelId;
if (channelId === null) return;
const channelUsers = voice.voiceUsers.get(channelId);
const user = channelUsers?.get(userId);
const tileId = isScreenshare ? userId + SCREENSHARE_TILE_ID_OFFSET : userId;
const username = isScreenshare
? user?.username
? `${user.username} (Screen)`
: `User ${userId} (Screen)`
: (user?.username ?? `User ${userId}`);
videoGrid.addStream(tileId, username, stream, {
isSelf: false,
audioUserId: userId,
isScreenshare,
});
videoModeCtrl?.checkVideoMode();
});
setOnRemoteVideoRemoved((userId, isScreenshare) => {
const tileId = isScreenshare ? userId + SCREENSHARE_TILE_ID_OFFSET : userId;
videoGrid?.removeStream(tileId);
videoModeCtrl?.checkVideoMode();
});
unsubscribers.push(() => clearOnRemoteVideo());
// Subscribe to voice store for camera/screenshare state changes only (not speaking ticks)
let prevVideoSignature = "";
unsubscribers.push(
voiceStore.subscribe((state) => {
try {
// Build a lightweight signature of video-relevant state (camera + screenshare)
let sig = (state.localCamera ? "c" : "") + (state.localScreenshare ? "s" : "");
const channelId = state.currentChannelId;
if (channelId !== null) {
const users = state.voiceUsers.get(channelId);
if (users) {
for (const [uid, u] of users) {
if (u.camera) sig += `:c${uid}`;
if (u.screenshare) sig += `:s${uid}`;
}
}
}
if (sig !== prevVideoSignature) {
prevVideoSignature = sig;
videoModeCtrl?.checkVideoMode();
}
} catch (err) {
log.error("Voice store subscription error", err);
}
}),
);
// Auto-update notifier — checks server for newer client version
if (apiConfig.host) {
const serverUrl = `https://${apiConfig.host}`;
const updateNotifier = createUpdateNotifier({ serverUrl });
updateNotifier.mount(root);
children.push(updateNotifier);
}
container.appendChild(root);
// --- Subscribe to channel changes ---
const unsubChannels = channelsStore.subscribeSelector(
(s) => s.activeChannelId,
() => {
try {
const active = getActiveChannel();
if (active !== null) {
// Voice is the only channel type that should keep the grid up;
// text, dm and announcement all mount a chat surface and must
// dismiss it, not just "text" (a dm/announcement switch used to
// leave the grid covering an unrelated channel's chat).
if (active.type !== "voice") {
videoModeCtrl?.showChat();
}
// Close DM profile sidebar when switching channels
if (active.type !== "dm") {
closeDmProfile();
} else {
// Also close when switching to a different DM
closeDmProfile();
}
channelCtrl?.mountChannel(
active.id,
resolveChannelName(active.id, active.name, active.type),
active.type,
);
} else {
// The active channel was cleared with nothing to replace it
// (deleted, or a DM closed while offline) — without this the
// previous channel's MessageList/composer stayed mounted and
// enabled against a channel the server no longer recognizes.
closeDmProfile();
channelCtrl?.destroyChannel();
}
} catch (err) {
log.error("Channel mount failed", err);
}
},
);
unsubscribers.push(unsubChannels);
const active = getActiveChannel();
if (active !== null) {
channelCtrl?.mountChannel(
active.id,
resolveChannelName(active.id, active.name, active.type),
active.type,
);
}
}
function destroy(): void {
log.info("MainPage destroying");
try {
// closeSettings() is otherwise only ever called from the overlay's own
// onClose — a non-user-initiated unmount (401, ban, server shutdown)
// left `settingsOpen` stale, and the next page to mount an (initially
// hidden) SettingsOverlay off that flag — ConnectPage, after logout —
// would show it over the login screen.
closeSettings();
teardownToast();
// Full voice cleanup — tears down room, callbacks, ws ref, serverHost.
// Prevents stale module-level state persisting across logout/reconnect cycles.
voiceCleanupAll();
// Custom emoji belong to the server this page was connected to. The set
// is module-global, so without this a switch to another server would keep
// rendering the previous one's shortcodes until its own list arrived.
clearCustomEmoji();
// Image/video/audio caches are module-global too — without this every
// clip viewed this session stays pinned (as a blob: URL or a cached
// data: URI) past logout.
clearAttachmentCaches();
// The lightbox is a module-level overlay appended straight to
// document.body — renderPage only clears #app, so a forced logout with
// it open would otherwise leave it floating over the login screen with
// live document listeners and a since-revoked blob URL (B6-15).
closeActiveLightbox();
autoIdle?.destroy();
autoIdle = null;
presenceSender.destroy();
channelCtrl?.destroyChannel();
channelCtrl = null;
reactionCtrl?.destroy();
reactionCtrl = null;
msgCtrl = null;
videoModeCtrl?.destroy();
videoModeCtrl = null;
videoGrid = null;
closeDmProfile();
dmProfileSlot = null;
for (const child of children) {
try {
child.destroy?.();
} catch (err) {
log.error("Child destroy error", err);
}
}
children = [];
for (const unsub of unsubscribers) {
try {
unsub();
} catch (err) {
log.error("Unsubscribe error", err);
}
}
unsubscribers = [];
if (banner !== null) {
banner.destroy();
banner = null;
}
} finally {
if (root !== null) {
root.remove();
root = null;
}
container = null;
}
}
return { mount, destroy };
}