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