fix: batch of 29 correctness fixes across server and client (#1369)

* fix(ws): 2 defect(s) (OC-0013, OC-0140)

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

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

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

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

* fix(server): 7 defect(s) (OC-0033, OC-0066, OC-0067, OC-0068, OC-0074, OC-0077, OC-0106)

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

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

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

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

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

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

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

* fix(plugin): 1 defect(s) (OC-0088)

* fix(plugin): 4 defect(s) (OC-0104, OC-0126, OC-0127, OC-0133)

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

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

* fix(api): 1 defect(s) (OC-0139)

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

* test(server): adapt existing tests to updated OpenDM and IncrementMentionCounts signatures

* style(plugin): modernize loops and goroutine spawns in race test

* fix(ws): mirror the focus admission gate in the post-subscribe revalidation

* fix(service): detach DM post-commit side effects from the request ctx, fail delete closed, add empty-fan-out fallback

* fix(plugin): preserve enabled intent when upgrade reactivation hits a runtime-less build

* chore(skills): harden bughunt-fix workflow and fold review lessons into bughunt-run/db-change

* Add comprehensive documentation for task-observer skill

- Introduced environments.md to outline activation setup, compaction behavior, and handoff-doc mode.
- Created skill-authoring.md detailing taxonomy, licensing, confidentiality, and editing rules for skill creation.
- Added weekly-review.md for a structured review process of OPEN observations, including scheduled and in-session fallback modes.

* chore(go): pin toolchain go1.26.6 (stdlib CVE fixes flagged by govulncheck)
This commit is contained in:
J3vb
2026-08-14 10:05:40 +02:00
committed by GitHub
parent c3837fa32c
commit db0275a290
67 changed files with 3329 additions and 163 deletions
@@ -299,6 +299,11 @@ export function createConnectPage(
// failure rather than whatever set it during this session.
setTransientError(null);
// Mirror MainPage.destroy(): clear settingsOpen so the next page to
// mount an (initially hidden) SettingsOverlay off that flag — MainPage,
// after a successful login — doesn't show it over the freshly loaded app.
closeSettings();
if (container && root) {
container.removeChild(root);
}
+9 -1
View File
@@ -577,7 +577,15 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
);
unsubscribers.push(
ws.on("call_declined", (payload) => {
ringCtrl?.cancel(payload.channel_id);
// 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
@@ -169,6 +169,12 @@ export function createInviteManagerController(opts: {
opening = true;
try {
const raw = await opts.api.getInvites();
// Re-derive liveness: a page teardown during the fetch nulls the root
// MainPage handed out, but the pre-await `root` const above still
// points at the now-detached node. Mounting on it anyway would create
// an instance whose document-level listeners nothing ever tears down.
const liveRoot = opts.getRoot();
if (liveRoot === null) return;
const invites = raw.filter((r) => !isInviteRevoked(r)).map(mapInviteResponse);
instance = createInviteManager({
invites,
@@ -198,9 +204,7 @@ export function createInviteManagerController(opts: {
showToast(message, "error");
},
});
if (root !== null) {
instance.mount(root);
}
instance.mount(liveRoot);
} catch (err) {
log.error("Failed to open invite manager", { error: String(err) });
showToast("Failed to load invites", "error");
@@ -261,6 +265,11 @@ export function createPinnedPanelController(opts: {
opening = true;
try {
const resp = await opts.api.getPins(channelId);
// Re-derive liveness: a page teardown during the fetch nulls the root
// MainPage handed out, but the pre-await `root` const above still
// points at the now-detached node — see InviteManagerController.open.
const liveRoot = opts.getRoot();
if (liveRoot === null) return;
const pins = resp.messages.map(mapToPinnedMessage);
instance = createPinnedMessages({
channelId,
@@ -286,9 +295,7 @@ export function createPinnedPanelController(opts: {
},
onClose: close,
});
if (root !== null) {
instance.mount(root);
}
instance.mount(liveRoot);
} catch (err) {
log.error("Failed to load pinned messages", { error: String(err) });
showToast("Failed to load pinned messages", "error");
@@ -6,6 +6,7 @@
import { createElement } from "@lib/dom";
import { createEmojiPicker } from "@components/EmojiPicker";
import { addOptimisticReaction, getChannelMessages } from "@stores/messages.store";
import { listCustomEmoji } from "@stores/emoji.store";
import type { WsClient } from "@lib/ws";
// ---------------------------------------------------------------------------
@@ -82,6 +83,10 @@ export function createReactionController(opts: ReactionControllerOptions): React
});
const picker = createEmojiPicker({
// Read the set at open time, not at controller construction: an
// emoji_update while the app is alive must be in the next picker the
// user opens (matches MessageInput.ts's composer picker).
customEmoji: listCustomEmoji(),
onSelect: (selectedEmoji: string) => {
closePicker();
sendReaction(msgId, selectedEmoji);
@@ -112,6 +112,13 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
// InviteManagerController / PinnedPanelController in OverlayManagers.ts.
let openingQuickSwitch = false;
// Set once this SidebarArea's own teardown unsubscriber has run.
// `sidebarWrapper.parentElement` can NOT be used for this: MainPage.destroy()
// removes an ancestor (`root`) and never detaches sidebarWrapper from its
// own parent, so that check is never true and a profile load that resolves
// after teardown mounts an orphaned overlay onto document.body.
let tornDown = false;
// Track the rename-group prompt so page teardown removes it — every other
// modal in this file assigns `activeModal` for the same reason.
let activePrompt: ModalInstance | null = null;
@@ -728,7 +735,7 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
}
// Ensure we haven't been cleaned up while awaiting
if (sidebarWrapper.parentElement === null) return;
if (tornDown) return;
quickSwitchInstance = createQuickSwitchOverlay({
profiles,
@@ -809,6 +816,7 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
});
unsubscribers.push(() => {
tornDown = true;
closeQuickSwitch();
});
@@ -989,6 +989,21 @@ describe("ConnectPage", () => {
page.destroy?.();
});
// OC-0083: destroy() must clear settingsOpen — otherwise MainPage, which
// mounts its own SettingsOverlay eagerly and shows it off a stale
// settingsOpen === true, pops the settings panel open over the freshly
// loaded app right after login.
it("closes settings on destroy so the flag doesn't leak into the next page", async () => {
const { closeSettings } = await import("../../src/stores/ui.store");
vi.mocked(closeSettings).mockClear();
const page = createConnectPage(makeCallbacks(), testProfiles);
page.mount(container);
page.destroy?.();
expect(closeSettings).toHaveBeenCalledTimes(1);
});
// --- setCredentials with password sets remember checkbox ---
it("setCredentials with password checks the remember password checkbox", async () => {
@@ -462,6 +462,35 @@ describe("MainPage — video grid, DM profile panel, calls, settings", () => {
expect(banner.style.display).not.toBe("none");
});
it("does not cancel an incoming ring when a fellow group-DM callee declines, only when the actual ringer does (OC-0114)", () => {
const ws = fakeWs();
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
page = createMainPage({ ws, api: fakeApi() });
page.mount(container);
// Alice (10) rings a group DM; this client is a third participant.
ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice" });
const banner = document.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement;
expect(banner.style.display).not.toBe("none");
// Bob (11), a different callee in the same group DM, declines. The
// server addresses call_declined to every other participant (not just
// the caller — it holds no call state to target with), so this client
// receives it too, but it must not silence a ring it is still deciding
// on: Bob declining is not Alice hanging up.
ws.emit("call_declined", { channel_id: 50, from_user: 11, username: "bob" });
expect(banner.style.display).not.toBe("none");
// The actual ringer's own call_declined (e.g. a glare decline) still
// cancels it.
ws.emit("call_declined", { channel_id: 50, from_user: 10, username: "alice" });
expect(banner.style.display).toBe("none");
});
it("clears settingsOpen on destroy so the next page (e.g. ConnectPage after logout) doesn't inherit a stale open overlay", () => {
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
@@ -513,6 +513,29 @@ describe("createPinnedPanelController", () => {
expect(api.getPins).toHaveBeenCalledOnce();
});
it("re-checks getRoot after the getPins() await and does not mount on a torn-down page", async () => {
// Same teardown-during-fetch race as InviteManagerController.open (OC-0055):
// toggle() must not mount on the pre-await root once the page has torn
// down while getPins() was in flight.
const api = makeMockApi();
let calls = 0;
const getRoot = vi.fn(() => {
calls++;
return calls === 1 ? root : null;
});
const controller = createPinnedPanelController({
api: api as never,
getRoot,
getCurrentChannelId: () => 42,
});
await controller.toggle();
expect(createPinnedMessages).not.toHaveBeenCalled();
expect(mockPinnedMessagesMount).not.toHaveBeenCalled();
});
it("cleanup is safe when no panel is open", () => {
const api = makeMockApi();
@@ -1049,6 +1072,30 @@ describe("createInviteManagerController (additional)", () => {
expect(mockShowToast).toHaveBeenCalledWith("Something went wrong", "error");
});
it("re-checks getRoot after the getInvites() await and does not mount on a torn-down page", async () => {
// Root is live when open() starts but the page tears down (MainPage.destroy
// nulls its root) while getInvites() is still in flight. open() must not
// resurrect the overlay on the stale, detached root it captured before the
// await — that leaves a document-level keydown listener with no teardown
// path (OC-0055).
const api = makeMockApi();
let calls = 0;
const getRoot = vi.fn(() => {
calls++;
return calls === 1 ? root : null;
});
const controller = createInviteManagerController({
api: api as never,
getRoot,
});
await controller.open();
expect(createInviteManager).not.toHaveBeenCalled();
expect(mockInviteManagerMount).not.toHaveBeenCalled();
});
it("onRevokeInvite passes invite code directly to API", async () => {
const api = makeMockApi();
@@ -7,16 +7,41 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const {
mockGetChannelMessages,
mockAddOptimisticReaction,
createMockEmojiPickerElement,
mockEmojiPickerDestroy,
} = vi.hoisted(() => ({
mockGetChannelMessages: vi.fn(
(): Array<{ id: number; reactions: Array<{ emoji: string; me: boolean }> }> => [],
),
mockAddOptimisticReaction: vi.fn(),
createMockEmojiPickerElement: () => document.createElement("div"),
mockEmojiPickerDestroy: vi.fn(),
}));
mockListCustomEmoji,
mockCreateEmojiPicker,
captured,
} = vi.hoisted(() => {
const captured: {
onSelect: ((emoji: string) => void) | null;
onClose: (() => void) | null;
} = { onSelect: null, onClose: null };
const createMockEmojiPickerElement = () => document.createElement("div");
const mockEmojiPickerDestroy = vi.fn();
return {
mockGetChannelMessages: vi.fn(
(): Array<{ id: number; reactions: Array<{ emoji: string; me: boolean }> }> => [],
),
mockAddOptimisticReaction: vi.fn(),
mockEmojiPickerDestroy,
mockListCustomEmoji: vi.fn((): Array<{ id: number; shortcode: string; url: string }> => []),
mockCreateEmojiPicker: vi.fn(
(opts: {
onSelect: (e: string) => void;
onClose: () => void;
customEmoji?: readonly { id: number; shortcode: string; url: string }[];
}) => {
captured.onSelect = opts.onSelect;
captured.onClose = opts.onClose;
return {
element: createMockEmojiPickerElement(),
destroy: mockEmojiPickerDestroy,
};
},
),
captured,
};
});
vi.mock("@lib/dom", () => ({
createElement: vi.fn((tag: string, attrs?: Record<string, string>) => {
@@ -32,18 +57,8 @@ vi.mock("@lib/dom", () => ({
}),
}));
let capturedOnSelect: ((emoji: string) => void) | null = null;
let capturedOnClose: (() => void) | null = null;
vi.mock("@components/EmojiPicker", () => ({
createEmojiPicker: vi.fn((opts: { onSelect: (e: string) => void; onClose: () => void }) => {
capturedOnSelect = opts.onSelect;
capturedOnClose = opts.onClose;
return {
element: createMockEmojiPickerElement(),
destroy: mockEmojiPickerDestroy,
};
}),
createEmojiPicker: mockCreateEmojiPicker,
}));
vi.mock("@stores/messages.store", () => ({
@@ -51,6 +66,10 @@ vi.mock("@stores/messages.store", () => ({
addOptimisticReaction: mockAddOptimisticReaction,
}));
vi.mock("@stores/emoji.store", () => ({
listCustomEmoji: mockListCustomEmoji,
}));
// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
@@ -87,8 +106,9 @@ function makeOpts(overrides: Partial<ReactionControllerOptions> = {}): ReactionC
describe("createReactionController", () => {
beforeEach(() => {
vi.clearAllMocks();
capturedOnSelect = null;
capturedOnClose = null;
mockListCustomEmoji.mockReturnValue([]);
captured.onSelect = null;
captured.onClose = null;
// Clean up any leftover DOM elements
document.querySelectorAll(".reaction-picker-wrap").forEach((el) => el.remove());
document.querySelectorAll("[data-testid]").forEach((el) => el.remove());
@@ -176,6 +196,32 @@ describe("createReactionController", () => {
expect(document.querySelector(".reaction-picker-wrap")).toBeNull();
});
it("passes the server's custom emoji to the picker (OC-0149)", () => {
const customEmoji = [{ id: 1, shortcode: "partyparrot", url: "/api/v1/emoji/1/image" }];
mockListCustomEmoji.mockReturnValue(customEmoji);
const btn = document.createElement("button");
btn.setAttribute("data-testid", "msg-react-1");
btn.getBoundingClientRect = vi.fn(() => ({
left: 500,
right: 530,
top: 100,
bottom: 130,
width: 30,
height: 30,
x: 500,
y: 100,
toJSON: () => {},
}));
document.body.appendChild(btn);
const opts = makeOpts();
const ctrl = createReactionController(opts);
ctrl.handleReaction(1, "");
expect(mockCreateEmojiPicker).toHaveBeenCalledWith(expect.objectContaining({ customEmoji }));
});
it("opens picker when react button exists", () => {
const btn = document.createElement("button");
btn.setAttribute("data-testid", "msg-react-1");
@@ -249,8 +295,8 @@ describe("createReactionController", () => {
ctrl.handleReaction(1, "");
// Simulate emoji selection from picker
expect(capturedOnSelect).not.toBeNull();
capturedOnSelect!("🎉");
expect(captured.onSelect).not.toBeNull();
captured.onSelect!("🎉");
expect(opts.ws.send).toHaveBeenCalledWith({
type: "reaction_add",
@@ -281,7 +327,7 @@ describe("createReactionController", () => {
const ctrl = createReactionController(opts);
ctrl.handleReaction(1, "");
capturedOnSelect!("🎉");
captured.onSelect!("🎉");
expect(mockEmojiPickerDestroy).toHaveBeenCalledOnce();
});
@@ -306,7 +352,7 @@ describe("createReactionController", () => {
const ctrl = createReactionController(opts);
ctrl.handleReaction(1, "");
capturedOnClose!();
captured.onClose!();
expect(mockEmojiPickerDestroy).toHaveBeenCalledOnce();
expect(document.querySelector(".reaction-picker-wrap")).toBeNull();
@@ -2087,6 +2087,45 @@ describe("SidebarArea", () => {
cleanup(result);
});
it("does not mount an orphaned overlay if torn down while profiles are still loading", async () => {
// MainPage.destroy() removes an ANCESTOR of sidebarWrapper (`root`) and
// never detaches sidebarWrapper from its own parent, so
// `sidebarWrapper.parentElement` stays non-null forever — it cannot be
// used as the "were we torn down while awaiting?" check. Reproduce that
// exactly: sidebarWrapper stays attached to `container` for the whole
// test, teardown runs (the real unsubscriber list, same as
// MainPage.destroy()'s loop) while loadProfiles() is still pending, and
// only then does the profile load resolve.
let resolveLoad!: () => void;
const pending = new Promise<void>((resolve) => {
resolveLoad = resolve;
});
(createProfileManager as MockedFn).mockReturnValueOnce({
loadProfiles: vi.fn().mockReturnValue(pending),
getAll: vi.fn().mockReturnValue([]),
store: { getState: () => ({ profiles: [], healthStatuses: new Map() }) },
});
const callsBefore = (createQuickSwitchOverlay as MockedFn).mock.calls.length;
const result = createSidebarArea(defaultOpts());
container.appendChild(result.sidebarWrapper);
result.openQuickSwitch();
// Teardown, as MainPage.destroy() runs it: every unsubscriber fires
// (including the one that calls closeQuickSwitch()), but sidebarWrapper
// is never removed from its parent.
for (const unsub of result.unsubscribers) unsub();
expect(result.sidebarWrapper.parentElement).not.toBeNull();
resolveLoad();
await pending;
// No overlay should have been created for a page that no longer exists.
expect((createQuickSwitchOverlay as MockedFn).mock.calls.length).toBe(callsBefore);
});
});
// -------------------------------------------------------------------------