mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(message-list): rebuild virtual window when scroll leaves the rendered range Scroll-driven renderWindow calls previously never rebuilt the DOM, so scrolling past the overscan showed only spacer blank space until an unrelated data change forced a full re-render. The window now rebuilds whenever the computed visible range is not fully contained in the rendered one, keeping the no-op (and the existing rebuild rate limiter) for ranges that are already rendered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(messages): refetch channel tail when revisiting a previously loaded channel The server only delivers live message broadcasts for the focused channel, so a channel's loaded window stops updating once the user switches away. Switching channels now drops the left channel's loaded flag so the next visit refetches the live tail, while keeping the old rows rendered until the refetch merges in (pending/failed rows are preserved). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(channel-sidebar): assign distinct slots when reordering channels with tied positions Categories whose channels share a position value (the server does not enforce uniqueness, and new channels default to position 0) previously produced an empty or partial reorder on drop, leaving the final order ambiguous. Tied slots are now nudged into a strictly increasing sequence before being reassigned, while already-distinct groups keep their range. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(embeds): keep the link-preview abort timer armed until the body is read The link-preview fetch previously cleared its 5 s abort timer as soon as response headers arrived, so reading the response body was unbounded in time and size. The timer is now cleared in a finally after the body read, so the timeout covers the whole request and the 50 KB parse cap applies as documented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(message-list): anchor scroll-to-bottom and jump-to-present controls outside the scroller The two floating controls were appended inside the overflow scroller, so they were part of its scrollable overflow and translated away with the content whenever the user scrolled up — precisely when they become visible. They now anchor to a position:relative frame that wraps the scroller, keeping them pinned to the viewport edge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(voice): reset the pinned input device before cycling the mic to default Selecting the default microphone (or losing the selected one to a hot unplug) only muted and unmuted the existing track, which kept capturing from the previously pinned device. The shared cycle now resets the capture device to the system default first so both paths actually reach it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(voice): re-check supersession after camera publish before announcing camera on A camera disable that completes while the enable's publishTrack call is still in flight now causes the enable to unpublish and stop its track and skip the enabled announcement, so the server's last word matches the local state instead of reporting a stopped camera as on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(voice): re-check supersession across the screenshare publish loop A screenshare disable that completes while a publish in the enable loop is still in flight now stops the loop before the remaining tracks are published; the enable attempt unpublishes and stops all of its tracks and skips the enabled announcement, so tracks held only by that attempt are released and the server's last word matches the local state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(sidebar): rethrow channel modal API failures so modals can recover The create/edit/delete channel callbacks caught API errors and only showed a toast, so the awaiting modal never saw the failure and left its submit button disabled with the in-flight label. The callbacks now rethrow after toasting, letting each modal re-enable its button and render its inline error so the user can retry without losing the form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(messages): carry pending and failed rows across the prepend trim When a scroll-up page pushed a channel past the per-channel cap, prependMessages trimmed the tail wholesale, deleting pending/failed optimistic rows that hold the only copy of the user's composed text. The trim now carries those rows across, matching the other window-replacing writers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(channel-sidebar): re-resolve the drop container when the sidebar re-renders mid-drag A store-driven sidebar re-render while a drag is in flight rebuilds the channel rows, detaching the container captured at mousedown; detached rows report all-zero rects, so the drop and the hover indicator could never resolve. The global handlers now re-target the live row, its container, and the store's current group snapshot before hit-testing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(delete-channel-modal): re-arm the confirm button when a delete fails The confirm button was only restored from the catch block, so a caller that handled the failure itself and resolved left the button disabled on 'Deleting...' with no way to retry. Restoration now runs in a finally block whenever the modal is still open, regardless of how the callback settled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(dm-sidebar): keep the presence dot when an avatar image loads The avatar swap cleared the whole circle before inserting the fetched image, which also removed the online/idle/dnd/offline dot on 1:1 rows. The initial now lives in its own node and only that node is replaced, so the presence dot survives the image arriving. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(formatting): compute the yesterday boundary from the calendar date The relative-day fence post was derived by subtracting a fixed 24 hours from local midnight, which lands inside the wrong calendar day when a DST transition makes the local day 23 or 25 hours long. It is now built from the calendar date directly, so hover and expanded message timestamps keep the correct Yesterday label around transitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(message-input): keep the empty-edit guard active while attachments are queued Edits are text-only, so a queued attachment no longer bypasses the empty-content guard while editing. Submitting an edit whose text was cleared is now refused with edit mode intact, matching the behavior when nothing is attached. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU --------- Co-authored-by: Claude <noreply@anthropic.com>
618 lines
20 KiB
TypeScript
618 lines
20 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
const fetchImageAsDataUrl = vi.hoisted(() => vi.fn());
|
|
|
|
// Only the network fetch is stubbed -- isSafeUrl/resolveServerUrl are
|
|
// reimplemented (not mocked away) so the raw-src-vs-authenticated-fetch
|
|
// distinction this suite exercises stays honest. Mirrors tests/unit/avatar.test.ts.
|
|
vi.mock("@components/message-list/attachments", () => ({
|
|
fetchImageAsDataUrl,
|
|
isSafeUrl: (url: string) => url.startsWith("https://") || url.startsWith("http://"),
|
|
resolveServerUrl: (url: string) => (url.startsWith("http") ? url : `https://server.test${url}`),
|
|
}));
|
|
|
|
import { createDmSidebar } from "../../src/components/DmSidebar";
|
|
import type { DmConversation } from "../../src/components/DmSidebar";
|
|
|
|
const makeConvo = (overrides: Partial<DmConversation> = {}): DmConversation => ({
|
|
channelId: 100,
|
|
userId: 1,
|
|
username: "Alice",
|
|
avatar: null,
|
|
status: "online",
|
|
lastMessage: "Hello!",
|
|
timestamp: "2025-01-01T00:00:00Z",
|
|
unread: false,
|
|
...overrides,
|
|
});
|
|
|
|
describe("DmSidebar", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
fetchImageAsDataUrl.mockReset();
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
});
|
|
|
|
it("renders the sidebar with search input", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const searchInput = container.querySelector(".dm-search");
|
|
expect(searchInput).not.toBeNull();
|
|
expect((searchInput as HTMLInputElement).placeholder).toBe("Find a conversation");
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("filters the conversation list as the search box is typed into", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [
|
|
makeConvo({ channelId: 1, username: "Alice" }),
|
|
makeConvo({ channelId: 2, username: "Bob" }),
|
|
],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const searchInput = container.querySelector(".dm-search") as HTMLInputElement;
|
|
searchInput.value = "ali";
|
|
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
const visibleNames = [...container.querySelectorAll(".dm-item")]
|
|
.filter((el) => (el as HTMLElement).style.display !== "none")
|
|
.map((el) => el.querySelector(".dm-name")?.textContent);
|
|
expect(visibleNames).toEqual(["Alice"]);
|
|
|
|
// Clearing the query shows every conversation again.
|
|
searchInput.value = "";
|
|
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
|
const allVisible = [...container.querySelectorAll(".dm-item")].every(
|
|
(el) => (el as HTMLElement).style.display !== "none",
|
|
);
|
|
expect(allVisible).toBe(true);
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
// The Friends nav item was removed in phase 6: it was a dead entry whose
|
|
// callback was never wired, and the plan's stated option was to delete it
|
|
// rather than build a friends list. This pins the deletion.
|
|
it("does not render a Friends nav item", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
expect(container.querySelector(".dm-nav-item")).toBeNull();
|
|
expect(container.textContent).not.toContain("Friends");
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("renders conversation items", () => {
|
|
const conversations: DmConversation[] = [
|
|
makeConvo({ userId: 1, username: "Alice" }),
|
|
makeConvo({ userId: 2, username: "Bob" }),
|
|
];
|
|
|
|
const sidebar = createDmSidebar({
|
|
conversations,
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const items = container.querySelectorAll(".dm-item");
|
|
expect(items.length).toBe(2);
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("sorts unread conversations first", () => {
|
|
const conversations: DmConversation[] = [
|
|
makeConvo({ userId: 1, username: "Alice", unread: false }),
|
|
makeConvo({ userId: 2, username: "Bob", unread: true }),
|
|
];
|
|
|
|
const sidebar = createDmSidebar({
|
|
conversations,
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const items = container.querySelectorAll(".dm-item");
|
|
// Bob (unread) should come first
|
|
expect(items[0]!.querySelector(".dm-name")!.textContent).toBe("Bob");
|
|
expect(items[1]!.querySelector(".dm-name")!.textContent).toBe("Alice");
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("shows unread dot for unread conversations", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ unread: true })],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const unreadDot = container.querySelector(".dm-unread");
|
|
expect(unreadDot).not.toBeNull();
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("calls onSelectConversation when a DM item is clicked", () => {
|
|
const onSelectConversation = vi.fn();
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ channelId: 42, userId: 42 })],
|
|
onSelectConversation,
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const item = container.querySelector(".dm-item") as HTMLElement;
|
|
item.click();
|
|
expect(onSelectConversation).toHaveBeenCalledWith(42);
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("calls onCloseDm when close button is clicked", () => {
|
|
const onCloseDm = vi.fn();
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ channelId: 42, userId: 42 })],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
onCloseDm,
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const closeBtn = container.querySelector(".dm-close") as HTMLButtonElement;
|
|
closeBtn.click();
|
|
expect(onCloseDm).toHaveBeenCalledWith(42);
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("calls onNewDm when add button is clicked", () => {
|
|
const onNewDm = vi.fn();
|
|
const sidebar = createDmSidebar({
|
|
conversations: [],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm,
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const addBtn = container.querySelector(".dm-add") as HTMLButtonElement;
|
|
addBtn.click();
|
|
expect(onNewDm).toHaveBeenCalledOnce();
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("shows avatar initial when no avatar image", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ username: "alice", avatar: null })],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const avatar = container.querySelector(".dm-avatar");
|
|
expect(avatar!.textContent).toBe("A");
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("shows avatar image when avatar URL is provided", async () => {
|
|
// <img src> cannot carry the bearer token an authenticated file route
|
|
// needs, so the picture is fetched through the same cert-pinned path
|
|
// attachments use and swapped in once the bytes arrive -- never assigned
|
|
// as a raw src.
|
|
fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,AAA");
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ avatar: "http://example.com/img.png" })],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
expect(fetchImageAsDataUrl).toHaveBeenCalledWith("http://example.com/img.png");
|
|
await vi.waitFor(() => {
|
|
const img = container.querySelector(".dm-avatar img") as HTMLImageElement;
|
|
expect(img).not.toBeNull();
|
|
expect(img.src).toBe("data:image/png;base64,AAA");
|
|
});
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("fetches a server-relative avatar through the authenticated path instead of leaving a raw <img src> that 404s", async () => {
|
|
fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,BBB");
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ username: "Bob", avatar: "/api/v1/files/42" })],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
// The letter fallback draws immediately, not skipped in favor of a
|
|
// broken <img>.
|
|
const avatar = container.querySelector(".dm-avatar") as HTMLDivElement;
|
|
expect(avatar.textContent).toBe("B");
|
|
|
|
await vi.waitFor(() => {
|
|
expect(fetchImageAsDataUrl).toHaveBeenCalledWith("https://server.test/api/v1/files/42");
|
|
const img = avatar.querySelector("img");
|
|
expect(img).not.toBeNull();
|
|
expect(img?.getAttribute("src")).toBe("data:image/png;base64,BBB");
|
|
});
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("keeps the presence dot when the fetched avatar image is swapped in", async () => {
|
|
fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,CCC");
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ avatar: "/api/v1/files/42", status: "online" })],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const avatar = container.querySelector(".dm-avatar") as HTMLDivElement;
|
|
// Dot and letter are both there before the bytes arrive.
|
|
expect(avatar.querySelector(".dm-status")).not.toBeNull();
|
|
expect(avatar.textContent).toBe("A");
|
|
|
|
await vi.waitFor(() => {
|
|
expect(avatar.querySelector("img")).not.toBeNull();
|
|
});
|
|
|
|
// The image replaces the letter only — presence survives the swap.
|
|
const dot = avatar.querySelector(".dm-status") as HTMLSpanElement;
|
|
expect(dot).not.toBeNull();
|
|
expect(dot.style.background).toBe("var(--green)");
|
|
expect(avatar.textContent).toBe("");
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("swaps fetched images into group faces, keeping the letter for members without one", async () => {
|
|
fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,DDD");
|
|
const sidebar = createDmSidebar({
|
|
conversations: [
|
|
makeConvo({
|
|
channelId: 9,
|
|
isGroup: true,
|
|
username: "Weekend Plans",
|
|
participants: [
|
|
{ id: 2, username: "Bob", avatar: "/api/v1/files/7" },
|
|
{ id: 3, username: "Carol", avatar: null },
|
|
],
|
|
}),
|
|
],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const faces = container.querySelectorAll(".dm-avatar-face");
|
|
expect(faces.length).toBe(2);
|
|
expect(faces[0]!.textContent).toBe("B");
|
|
|
|
await vi.waitFor(() => {
|
|
expect(faces[0]!.querySelector("img")).not.toBeNull();
|
|
});
|
|
expect(faces[0]!.textContent).toBe("");
|
|
// Carol has no avatar: her letter stays and nothing was fetched for her.
|
|
expect(faces[1]!.textContent).toBe("C");
|
|
expect(faces[1]!.querySelector("img")).toBeNull();
|
|
expect(fetchImageAsDataUrl).toHaveBeenCalledTimes(1);
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("marks active conversation with active class", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ active: true })],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const item = container.querySelector(".dm-item");
|
|
expect(item!.classList.contains("active")).toBe(true);
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("cleans up on destroy", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
expect(container.querySelector(".channel-sidebar")).not.toBeNull();
|
|
|
|
sidebar.destroy?.();
|
|
expect(container.querySelector(".channel-sidebar")).toBeNull();
|
|
});
|
|
|
|
it("renders back header when onBack is provided", () => {
|
|
const onBack = vi.fn();
|
|
const sidebar = createDmSidebar({
|
|
conversations: [],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
onBack,
|
|
serverName: "OwnCord",
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const backHeader = container.querySelector('[data-testid="dm-back-header"]');
|
|
expect(backHeader).not.toBeNull();
|
|
expect(backHeader!.textContent).toContain("Back to OwnCord");
|
|
expect(backHeader!.textContent).toContain("Return to channels");
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("clicking back header calls onBack", () => {
|
|
const onBack = vi.fn();
|
|
const sidebar = createDmSidebar({
|
|
conversations: [],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
onBack,
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const backHeader = container.querySelector('[data-testid="dm-back-header"]') as HTMLDivElement;
|
|
backHeader.click();
|
|
expect(onBack).toHaveBeenCalledOnce();
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("does not render back header when onBack is undefined", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const backHeader = container.querySelector('[data-testid="dm-back-header"]');
|
|
expect(backHeader).toBeNull();
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("applies correct status color to DM status dot", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [
|
|
makeConvo({ userId: 1, username: "Alice", status: "online" }),
|
|
makeConvo({ userId: 2, username: "Bob", status: "dnd" }),
|
|
makeConvo({ userId: 3, username: "Charlie", status: "idle" }),
|
|
makeConvo({ userId: 4, username: "Dave", status: "offline" }),
|
|
],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const statusDots = container.querySelectorAll(".dm-status") as NodeListOf<HTMLSpanElement>;
|
|
const colors = Array.from(statusDots).map((dot) => dot.style.background);
|
|
|
|
expect(colors).toContain("var(--green)");
|
|
expect(colors).toContain("var(--red)");
|
|
expect(colors).toContain("var(--yellow)");
|
|
expect(colors).toContain("var(--text-micro)");
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("close button click does not trigger conversation select", () => {
|
|
const onSelectConversation = vi.fn();
|
|
const onCloseDm = vi.fn();
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ channelId: 42, userId: 42 })],
|
|
onSelectConversation,
|
|
onNewDm: vi.fn(),
|
|
onCloseDm,
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const closeBtn = container.querySelector(".dm-close") as HTMLButtonElement;
|
|
closeBtn.click();
|
|
|
|
// Close fires but select should NOT fire (stopPropagation)
|
|
expect(onCloseDm).toHaveBeenCalledWith(42);
|
|
expect(onSelectConversation).not.toHaveBeenCalled();
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("clicking a DM item marks it active and deactivates siblings", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [
|
|
makeConvo({ userId: 1, username: "Alice" }),
|
|
makeConvo({ userId: 2, username: "Bob" }),
|
|
],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const items = container.querySelectorAll(".dm-item");
|
|
(items[0] as HTMLElement).click();
|
|
expect(items[0]!.classList.contains("active")).toBe(true);
|
|
|
|
// Click the second item — first should lose active
|
|
(items[1] as HTMLElement).click();
|
|
expect(items[1]!.classList.contains("active")).toBe(true);
|
|
expect(items[0]!.classList.contains("active")).toBe(false);
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("uses default avatar color when avatarColor is not provided", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ avatar: null, avatarColor: undefined })],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const avatar = container.querySelector(".dm-avatar") as HTMLDivElement;
|
|
// Default background is #5865F2
|
|
expect(avatar.style.background).toBe("rgb(88, 101, 242)");
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("uses custom avatar color when provided", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ avatar: null, avatarColor: "#ff0000" })],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const avatar = container.querySelector(".dm-avatar") as HTMLDivElement;
|
|
expect(avatar.style.background).toBe("rgb(255, 0, 0)");
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("does not show unread dot for read conversations", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ unread: false })],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const unreadDot = container.querySelector(".dm-unread");
|
|
expect(unreadDot).toBeNull();
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("back header defaults to 'Server' when serverName not provided", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
onBack: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const backHeader = container.querySelector('[data-testid="dm-back-header"]');
|
|
expect(backHeader!.textContent).toContain("Back to Server");
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
|
|
it("uses offline status color for unknown status values", () => {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [makeConvo({ status: undefined })],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
|
|
const statusDot = container.querySelector(".dm-status") as HTMLSpanElement;
|
|
// undefined status falls back to "offline" key
|
|
expect(statusDot.style.background).toBe("var(--text-micro)");
|
|
|
|
sidebar.destroy?.();
|
|
});
|
|
});
|
|
|
|
// ── Unread / mention badges ────────────────────────────────────────────────
|
|
//
|
|
// A DM used to show only a dot, which said "something happened" but not how
|
|
// much — and never distinguished a mention from ordinary traffic.
|
|
|
|
describe("DmSidebar — unread and mention badges", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
});
|
|
|
|
function mountWith(convo: DmConversation): void {
|
|
const sidebar = createDmSidebar({
|
|
conversations: [convo],
|
|
onSelectConversation: vi.fn(),
|
|
onNewDm: vi.fn(),
|
|
});
|
|
sidebar.mount(container);
|
|
}
|
|
|
|
it("renders the unread count, not a bare dot", () => {
|
|
mountWith(makeConvo({ channelId: 7, userId: 7, unread: true, unreadCount: 4 }));
|
|
|
|
const badge = container.querySelector('[data-testid="dm-unread-7"]');
|
|
expect(badge?.textContent).toBe("4");
|
|
expect(container.querySelector(".dm-unread")).toBeNull();
|
|
});
|
|
|
|
it("renders a mention badge instead of the unread badge", () => {
|
|
mountWith(
|
|
makeConvo({ channelId: 7, userId: 7, unread: true, unreadCount: 5, mentionCount: 2 }),
|
|
);
|
|
|
|
expect(container.querySelector('[data-testid="dm-mentions-7"]')?.textContent).toBe("2");
|
|
expect(container.querySelector('[data-testid="dm-unread-7"]')).toBeNull();
|
|
});
|
|
|
|
it("pluralises the badge tooltips", () => {
|
|
mountWith(makeConvo({ channelId: 7, userId: 7, unread: true, unreadCount: 1 }));
|
|
expect((container.querySelector('[data-testid="dm-unread-7"]') as HTMLElement).title).toBe(
|
|
"1 unread message",
|
|
);
|
|
});
|
|
|
|
it("falls back to the dot when the payload carries no counts", () => {
|
|
mountWith(makeConvo({ channelId: 7, userId: 7, unread: true }));
|
|
|
|
expect(container.querySelector(".dm-unread")).not.toBeNull();
|
|
expect(container.querySelector('[data-testid="dm-unread-7"]')).toBeNull();
|
|
});
|
|
|
|
it("renders no badge and no dot for a read conversation", () => {
|
|
mountWith(
|
|
makeConvo({ channelId: 7, userId: 7, unread: false, unreadCount: 0, mentionCount: 0 }),
|
|
);
|
|
|
|
expect(container.querySelector(".dm-unread")).toBeNull();
|
|
expect(container.querySelector('[data-testid="dm-unread-7"]')).toBeNull();
|
|
expect(container.querySelector('[data-testid="dm-mentions-7"]')).toBeNull();
|
|
});
|
|
});
|