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>
1390 lines
48 KiB
TypeScript
1390 lines
48 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
/** Captured emoji picker callbacks so tests can simulate selection. */
|
|
let lastEmojiPickerOptions: { onSelect: (emoji: string) => void; onClose: () => void } | null =
|
|
null;
|
|
|
|
vi.mock("@components/EmojiPicker", () => ({
|
|
createEmojiPicker: (opts: { onSelect: (emoji: string) => void; onClose: () => void }) => {
|
|
lastEmojiPickerOptions = opts;
|
|
const element = document.createElement("div");
|
|
element.classList.add("emoji-picker");
|
|
return { element, destroy: vi.fn() };
|
|
},
|
|
}));
|
|
|
|
/** Captured GIF picker callbacks so tests can simulate selection. */
|
|
type CapturedGifPickerOptions = {
|
|
onSelect: (url: string) => void;
|
|
onClose: () => void;
|
|
onUnavailable?: (reason: string) => void;
|
|
};
|
|
let lastGifPickerOptions: CapturedGifPickerOptions | null = null;
|
|
|
|
vi.mock("@components/GifPicker", () => ({
|
|
createGifPicker: (opts: CapturedGifPickerOptions) => {
|
|
lastGifPickerOptions = opts;
|
|
const element = document.createElement("div");
|
|
element.classList.add("gif-picker");
|
|
return { element, destroy: vi.fn() };
|
|
},
|
|
}));
|
|
|
|
import {
|
|
createMessageInput,
|
|
wrapWithMarker,
|
|
type MessageInputOptions,
|
|
} from "@components/MessageInput";
|
|
import type { GifApi } from "@lib/gifProvider";
|
|
|
|
/** GIF endpoints on the user's own server (never api.klipy.com). */
|
|
const stubGifApi: GifApi = {
|
|
gifSearch: vi.fn(async () => ({ results: [] })),
|
|
gifTrending: vi.fn(async () => ({ results: [] })),
|
|
};
|
|
|
|
function makeOptions(overrides: Partial<MessageInputOptions> = {}): MessageInputOptions {
|
|
return {
|
|
channelId: 1,
|
|
channelName: "general",
|
|
gifApi: stubGifApi,
|
|
onSend: vi.fn(),
|
|
onTyping: vi.fn(),
|
|
onEditMessage: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("MessageInput", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
lastEmojiPickerOptions = null;
|
|
lastGifPickerOptions = null;
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
});
|
|
|
|
it("mounts with message-input-wrap class", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
expect(container.querySelector(".message-input-wrap")).not.toBeNull();
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("has textarea with correct placeholder", () => {
|
|
const opts = makeOptions({ channelName: "random" });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
expect(textarea).not.toBeNull();
|
|
expect(textarea.placeholder).toBe("Message #random");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("send button click calls onSend with textarea content", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "Hello world";
|
|
|
|
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
|
|
sendBtn.click();
|
|
|
|
expect(opts.onSend).toHaveBeenCalledWith("Hello world", null, []);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("enter key sends message", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "Enter message";
|
|
|
|
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
|
|
expect(opts.onSend).toHaveBeenCalledWith("Enter message", null, []);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("shift+enter does NOT send (just newlines)", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "Line 1";
|
|
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true }),
|
|
);
|
|
|
|
expect(opts.onSend).not.toHaveBeenCalled();
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("empty textarea does not send", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "";
|
|
|
|
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
|
|
sendBtn.click();
|
|
|
|
expect(opts.onSend).not.toHaveBeenCalled();
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("setReplyTo shows reply bar", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.setReplyTo(42, "testuser");
|
|
|
|
const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
|
|
expect(replyBar.classList.contains("visible")).toBe(true);
|
|
expect(replyBar.textContent).toContain("testuser");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("clearReply hides reply bar", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.setReplyTo(42, "testuser");
|
|
comp.clearReply();
|
|
|
|
const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
|
|
expect(replyBar.classList.contains("visible")).toBe(false);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("startEdit sets textarea value and shows edit bar", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.startEdit(99, "editing this");
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
expect(textarea.value).toBe("editing this");
|
|
|
|
// The edit bar is the second .reply-bar
|
|
const bars = container.querySelectorAll(".reply-bar");
|
|
const editBar = bars[1] as HTMLDivElement;
|
|
expect(editBar.classList.contains("visible")).toBe(true);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("cancelEdit clears textarea and hides edit bar", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.startEdit(99, "editing this");
|
|
comp.cancelEdit();
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
expect(textarea.value).toBe("");
|
|
|
|
const bars = container.querySelectorAll(".reply-bar");
|
|
const editBar = bars[1] as HTMLDivElement;
|
|
expect(editBar.classList.contains("visible")).toBe(false);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("typing emits onTyping (throttled)", () => {
|
|
vi.useFakeTimers();
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
|
|
// First input should trigger onTyping
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
expect(opts.onTyping).toHaveBeenCalledTimes(1);
|
|
|
|
// Immediate second input should NOT trigger (throttled at 3s)
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
expect(opts.onTyping).toHaveBeenCalledTimes(1);
|
|
|
|
// After 3 seconds, should fire again
|
|
vi.advanceTimersByTime(3000);
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
expect(opts.onTyping).toHaveBeenCalledTimes(2);
|
|
|
|
vi.useRealTimers();
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("attach button is disabled with tooltip", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const attachBtn = container.querySelector(".attach-btn") as HTMLButtonElement;
|
|
expect(attachBtn).not.toBeNull();
|
|
expect(attachBtn.disabled).toBe(true);
|
|
expect(attachBtn.title).toBe("File uploads not available");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("debounces rapid sends", () => {
|
|
vi.useFakeTimers();
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
|
|
|
|
textarea.value = "msg1";
|
|
sendBtn.click();
|
|
expect(opts.onSend).toHaveBeenCalledTimes(1);
|
|
|
|
// Immediately try to send again (within 200ms debounce)
|
|
textarea.value = "msg2";
|
|
sendBtn.click();
|
|
expect(opts.onSend).toHaveBeenCalledTimes(1); // still 1
|
|
|
|
// After debounce period
|
|
vi.advanceTimersByTime(200);
|
|
textarea.value = "msg3";
|
|
sendBtn.click();
|
|
expect(opts.onSend).toHaveBeenCalledTimes(2);
|
|
|
|
vi.useRealTimers();
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── Edit mode sends via onEditMessage ──
|
|
|
|
it("sending in edit mode calls onEditMessage instead of onSend", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.startEdit(77, "old content");
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "updated content";
|
|
|
|
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
|
|
sendBtn.click();
|
|
|
|
expect(opts.onEditMessage).toHaveBeenCalledWith(77, "updated content");
|
|
expect(opts.onSend).not.toHaveBeenCalled();
|
|
|
|
// After send, edit bar should be hidden and textarea cleared
|
|
const bars = container.querySelectorAll(".reply-bar");
|
|
const editBar = bars[1] as HTMLDivElement;
|
|
expect(editBar.classList.contains("visible")).toBe(false);
|
|
expect(textarea.value).toBe("");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── Emptied edits are refused, never submitted ──
|
|
|
|
it("does not submit an emptied edit while an attachment is queued", async () => {
|
|
const uploadResult = { id: "srv-9", url: "http://server/pic.png", filename: "pic.png" };
|
|
const onUploadFile = vi.fn(async () => uploadResult);
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
// Queue an attachment first, then enter edit mode.
|
|
const testFile = new File(["image data"], "pic.png", { type: "image/png" });
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
await vi.waitFor(() => {
|
|
expect(onUploadFile).toHaveBeenCalledWith(testFile);
|
|
});
|
|
// Wait for the upload to fully settle so the send is not blocked by the
|
|
// uploads-in-flight guard instead of the empty-content one.
|
|
const previewBar = container.querySelector(".attachment-preview-bar");
|
|
await vi.waitFor(() => {
|
|
expect(previewBar!.querySelector(".uploading")).toBeNull();
|
|
});
|
|
|
|
comp.startEdit(77, "old content");
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "";
|
|
|
|
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
|
|
sendBtn.click();
|
|
|
|
// Same as with no attachment queued: the empty edit is refused and edit
|
|
// mode survives.
|
|
expect(opts.onEditMessage).not.toHaveBeenCalled();
|
|
expect(opts.onSend).not.toHaveBeenCalled();
|
|
const bars = container.querySelectorAll(".reply-bar");
|
|
const editBar = bars[1] as HTMLDivElement;
|
|
expect(editBar.classList.contains("visible")).toBe(true);
|
|
|
|
// Typing real content and sending again still submits the edit.
|
|
textarea.value = "fixed content";
|
|
sendBtn.click();
|
|
expect(opts.onEditMessage).toHaveBeenCalledWith(77, "fixed content");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("emptied edit with no attachment queued is a no-op that stays in edit mode", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.startEdit(88, "old content");
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "";
|
|
|
|
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
|
|
sendBtn.click();
|
|
|
|
expect(opts.onEditMessage).not.toHaveBeenCalled();
|
|
expect(opts.onSend).not.toHaveBeenCalled();
|
|
const bars = container.querySelectorAll(".reply-bar");
|
|
const editBar = bars[1] as HTMLDivElement;
|
|
expect(editBar.classList.contains("visible")).toBe(true);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("sends an attachment-only message with no text", async () => {
|
|
const uploadResult = { id: "srv-7", url: "http://server/pic.png", filename: "pic.png" };
|
|
const onUploadFile = vi.fn(async () => uploadResult);
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const testFile = new File(["image data"], "pic.png", { type: "image/png" });
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
await vi.waitFor(() => {
|
|
expect(onUploadFile).toHaveBeenCalledWith(testFile);
|
|
});
|
|
const previewBar = container.querySelector(".attachment-preview-bar");
|
|
await vi.waitFor(() => {
|
|
expect(previewBar!.querySelector(".uploading")).toBeNull();
|
|
});
|
|
|
|
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
|
|
sendBtn.click();
|
|
|
|
expect(opts.onSend).toHaveBeenCalledWith("", null, ["srv-7"]);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── Reply context is included in send ──
|
|
|
|
it("sending with reply includes replyTo messageId", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.setReplyTo(55, "replyuser");
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "reply content";
|
|
|
|
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
|
|
sendBtn.click();
|
|
|
|
expect(opts.onSend).toHaveBeenCalledWith("reply content", 55, []);
|
|
|
|
// Reply bar should be hidden after send
|
|
const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
|
|
expect(replyBar.classList.contains("visible")).toBe(false);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── Escape key behavior ──
|
|
|
|
it("escape key cancels edit mode", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.startEdit(88, "editing");
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
|
|
// Edit bar should be hidden
|
|
const bars = container.querySelectorAll(".reply-bar");
|
|
const editBar = bars[1] as HTMLDivElement;
|
|
expect(editBar.classList.contains("visible")).toBe(false);
|
|
expect(textarea.value).toBe("");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("escape key clears reply when not in edit mode", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.setReplyTo(44, "replyuser");
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
|
|
const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
|
|
expect(replyBar.classList.contains("visible")).toBe(false);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── ArrowUp on empty textarea dispatches edit-last-message ──
|
|
|
|
it("ArrowUp on empty textarea dispatches edit-last-message custom event", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "";
|
|
|
|
const listener = vi.fn();
|
|
container.addEventListener("edit-last-message", listener);
|
|
|
|
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("ArrowUp with content in textarea does NOT dispatch edit-last-message", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "some text";
|
|
|
|
const listener = vi.fn();
|
|
container.addEventListener("edit-last-message", listener);
|
|
|
|
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── File attachment via onUploadFile ──
|
|
|
|
it("attach button is enabled when onUploadFile is provided", () => {
|
|
const opts = makeOptions({
|
|
onUploadFile: vi.fn(async () => ({ id: "a1", url: "http://x.png", filename: "x.png" })),
|
|
});
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const attachBtn = container.querySelector(".attach-btn") as HTMLButtonElement;
|
|
expect(attachBtn.disabled).toBe(false);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("file picker accept attribute only advertises extensions the MIME allowlist accepts", () => {
|
|
const opts = makeOptions({
|
|
onUploadFile: vi.fn(async () => ({ id: "a1", url: "http://x.png", filename: "x.png" })),
|
|
});
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
// .rar/.7z were advertised here but rejected by ALLOWED_TYPES on pick —
|
|
// an always-rejected picker option.
|
|
expect(fileInput.accept).not.toContain(".rar");
|
|
expect(fileInput.accept).not.toContain(".7z");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("file upload shows preview and sends attachment ID with message", async () => {
|
|
const uploadResult = { id: "srv-123", url: "http://server/file.png", filename: "file.png" };
|
|
const onUploadFile = vi.fn(async () => uploadResult);
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
// Simulate file selection via the hidden input
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
expect(fileInput).not.toBeNull();
|
|
|
|
const testFile = new File(["image data"], "test.png", { type: "image/png" });
|
|
Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
// Wait for the async upload to complete
|
|
await vi.waitFor(() => {
|
|
expect(onUploadFile).toHaveBeenCalledWith(testFile);
|
|
});
|
|
|
|
// Preview bar should be visible
|
|
const previewBar = container.querySelector(".attachment-preview-bar");
|
|
expect(previewBar!.classList.contains("visible")).toBe(true);
|
|
|
|
// Now send a message -- should include the attachment ID
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "with attachment";
|
|
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
|
|
sendBtn.click();
|
|
|
|
expect(opts.onSend).toHaveBeenCalledWith("with attachment", null, ["srv-123"]);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("rejects file exceeding 100 MB size limit", async () => {
|
|
const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
// Create a file > 100MB
|
|
const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" });
|
|
Object.defineProperty(bigFile, "size", { value: 101 * 1024 * 1024 });
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
Object.defineProperty(fileInput, "files", { value: [bigFile], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
// Give async handlers a tick
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
// Upload should NOT have been called
|
|
expect(onUploadFile).not.toHaveBeenCalled();
|
|
|
|
// Error message should be displayed
|
|
const error = container.querySelector(".attachment-upload-error");
|
|
expect(error).not.toBeNull();
|
|
expect(error!.textContent).toContain("too large");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("upload error makes the (initially hidden) preview bar visible", async () => {
|
|
const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
// Nothing queued yet, so the preview bar starts without the "visible"
|
|
// class -- app.css only shows it via .visible.
|
|
const previewBar = container.querySelector(".attachment-preview-bar");
|
|
expect(previewBar!.classList.contains("visible")).toBe(false);
|
|
|
|
const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" });
|
|
Object.defineProperty(bigFile, "size", { value: 101 * 1024 * 1024 });
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
Object.defineProperty(fileInput, "files", { value: [bigFile], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
expect(container.querySelector(".attachment-upload-error")).not.toBeNull();
|
|
expect(previewBar!.classList.contains("visible")).toBe(true);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("preview bar loses 'visible' again once the error auto-dismisses with nothing else queued", async () => {
|
|
vi.useFakeTimers();
|
|
const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" });
|
|
Object.defineProperty(bigFile, "size", { value: 101 * 1024 * 1024 });
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
Object.defineProperty(fileInput, "files", { value: [bigFile], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
const previewBar = container.querySelector(".attachment-preview-bar");
|
|
expect(previewBar!.classList.contains("visible")).toBe(true);
|
|
|
|
await vi.advanceTimersByTimeAsync(4000);
|
|
expect(container.querySelector(".attachment-upload-error")).toBeNull();
|
|
expect(previewBar!.classList.contains("visible")).toBe(false);
|
|
|
|
vi.useRealTimers();
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("rejects unsupported file types", async () => {
|
|
const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const badFile = new File(["exe data"], "bad.exe", { type: "application/x-msdownload" });
|
|
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
Object.defineProperty(fileInput, "files", { value: [badFile], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
expect(onUploadFile).not.toHaveBeenCalled();
|
|
const error = container.querySelector(".attachment-upload-error");
|
|
expect(error).not.toBeNull();
|
|
expect(error!.textContent).toContain("is not a supported file type");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("shows upload error when onUploadFile rejects", async () => {
|
|
const onUploadFile = vi.fn(async () => {
|
|
throw new Error("Server exploded");
|
|
});
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const file = new File(["data"], "doc.pdf", { type: "application/pdf" });
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
Object.defineProperty(fileInput, "files", { value: [file], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
// Wait for the async rejection
|
|
await vi.waitFor(() => {
|
|
const error = container.querySelector(".attachment-upload-error");
|
|
expect(error).not.toBeNull();
|
|
expect(error!.textContent).toContain("Server exploded");
|
|
});
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("blocks send while uploads are still in flight", async () => {
|
|
// Create an upload that never resolves during the test
|
|
let resolveUpload: ((v: { id: string; url: string; filename: string }) => void) | null = null;
|
|
const onUploadFile = vi.fn(
|
|
() =>
|
|
new Promise<{ id: string; url: string; filename: string }>((res) => {
|
|
resolveUpload = res;
|
|
}),
|
|
);
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const file = new File(["data"], "doc.txt", { type: "text/plain" });
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
Object.defineProperty(fileInput, "files", { value: [file], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
// The upload is in flight but not resolved
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "trying to send";
|
|
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
|
|
sendBtn.click();
|
|
|
|
// Should NOT have sent -- upload still pending
|
|
expect(opts.onSend).not.toHaveBeenCalled();
|
|
// Should show "wait" error
|
|
const error = container.querySelector(".attachment-upload-error");
|
|
expect(error).not.toBeNull();
|
|
expect(error!.textContent).toContain("wait for uploads");
|
|
|
|
// Now resolve the upload
|
|
resolveUpload!({ id: "done", url: "http://x", filename: "doc.txt" });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("remove button removes attachment preview while upload is pending", async () => {
|
|
// Use a long-running upload so we can click remove while it's still pending
|
|
let resolveUpload: ((v: { id: string; url: string; filename: string }) => void) | null = null;
|
|
const onUploadFile = vi.fn(
|
|
() =>
|
|
new Promise<{ id: string; url: string; filename: string }>((res) => {
|
|
resolveUpload = res;
|
|
}),
|
|
);
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const file = new File(["text"], "file.txt", { type: "text/plain" });
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
Object.defineProperty(fileInput, "files", { value: [file], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
// Wait for the preview to appear (upload started but not resolved)
|
|
await vi.waitFor(() => {
|
|
expect(onUploadFile).toHaveBeenCalled();
|
|
});
|
|
|
|
// Preview bar should be visible with the uploading item
|
|
const previewBar = container.querySelector(".attachment-preview-bar");
|
|
expect(previewBar!.classList.contains("visible")).toBe(true);
|
|
|
|
// Remove the attachment while upload is still pending (tempId still matches)
|
|
const removeBtn = container.querySelector('[data-testid="attachment-remove"]') as HTMLElement;
|
|
expect(removeBtn).not.toBeNull();
|
|
removeBtn.click();
|
|
|
|
// Preview bar should lose visible class (all attachments removed from list)
|
|
expect(previewBar!.classList.contains("visible")).toBe(false);
|
|
|
|
// The preview item DOM element should be removed from the bar
|
|
expect(previewBar!.querySelector(".attachment-preview-item")).toBeNull();
|
|
|
|
// Clean up the pending promise
|
|
resolveUpload!({ id: "done", url: "http://x", filename: "file.txt" });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("remove button removes attachment preview after the upload has completed", async () => {
|
|
const uploadResult = { id: "srv-123", url: "http://server/file.png", filename: "file.png" };
|
|
const onUploadFile = vi.fn(async () => uploadResult);
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const testFile = new File(["image data"], "test.png", { type: "image/png" });
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
// Wait for the upload to resolve — the entry's id is now the server id,
|
|
// not the tempId the remove button was created with.
|
|
await vi.waitFor(() => {
|
|
expect(onUploadFile).toHaveBeenCalledWith(testFile);
|
|
});
|
|
const previewBar = container.querySelector(".attachment-preview-bar");
|
|
await vi.waitFor(() => {
|
|
expect(previewBar!.querySelector(".uploading")).toBeNull();
|
|
});
|
|
|
|
const removeBtn = container.querySelector('[data-testid="attachment-remove"]') as HTMLElement;
|
|
expect(removeBtn).not.toBeNull();
|
|
removeBtn.click();
|
|
|
|
expect(previewBar!.classList.contains("visible")).toBe(false);
|
|
expect(previewBar!.querySelector(".attachment-preview-item")).toBeNull();
|
|
|
|
// Sending now must not include the removed attachment.
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "no attachment";
|
|
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
|
|
sendBtn.click();
|
|
expect(opts.onSend).toHaveBeenCalledWith("no attachment", null, []);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("refuses to attach a file while editing a message", async () => {
|
|
const onUploadFile = vi.fn(async () => ({
|
|
id: "srv-1",
|
|
url: "http://x.png",
|
|
filename: "x.png",
|
|
}));
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.startEdit(77, "old content");
|
|
|
|
const file = new File(["data"], "photo.png", { type: "image/png" });
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
Object.defineProperty(fileInput, "files", { value: [file], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
// Must not upload, and must not silently ride along with the next
|
|
// ordinary send once the edit is cancelled.
|
|
expect(onUploadFile).not.toHaveBeenCalled();
|
|
expect(container.querySelector(".attachment-upload-error")).not.toBeNull();
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── setReplyTo clears edit mode ──
|
|
|
|
it("setReplyTo hides edit bar if editing", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.startEdit(88, "editing");
|
|
const bars = container.querySelectorAll(".reply-bar");
|
|
const editBar = bars[1] as HTMLDivElement;
|
|
expect(editBar.classList.contains("visible")).toBe(true);
|
|
|
|
comp.setReplyTo(55, "replying");
|
|
|
|
// Edit bar hidden, reply bar shown
|
|
expect(editBar.classList.contains("visible")).toBe(false);
|
|
const replyBar = bars[0] as HTMLDivElement;
|
|
expect(replyBar.classList.contains("visible")).toBe(true);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("setReplyTo while editing clears the stale edit text from the textarea", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.startEdit(88, "old message body");
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
expect(textarea.value).toBe("old message body");
|
|
|
|
comp.setReplyTo(55, "replying");
|
|
|
|
// The edit text must not survive into reply mode -- otherwise Enter
|
|
// reposts it verbatim as a duplicate reply.
|
|
expect(textarea.value).toBe("");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── startEdit clears reply mode ──
|
|
|
|
it("startEdit hides reply bar if replying", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.setReplyTo(55, "replying");
|
|
const bars = container.querySelectorAll(".reply-bar");
|
|
const replyBar = bars[0] as HTMLDivElement;
|
|
expect(replyBar.classList.contains("visible")).toBe(true);
|
|
|
|
comp.startEdit(88, "now editing");
|
|
|
|
// Reply bar hidden, edit bar shown
|
|
expect(replyBar.classList.contains("visible")).toBe(false);
|
|
const editBar = bars[1] as HTMLDivElement;
|
|
expect(editBar.classList.contains("visible")).toBe(true);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── Reply close button ──
|
|
|
|
it("clicking reply close button hides reply bar", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
comp.setReplyTo(42, "replyuser");
|
|
const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
|
|
expect(replyBar.classList.contains("visible")).toBe(true);
|
|
|
|
const closeBtn = replyBar.querySelector(".reply-close") as HTMLElement;
|
|
closeBtn.click();
|
|
|
|
expect(replyBar.classList.contains("visible")).toBe(false);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── Textarea auto-resize ──
|
|
|
|
it("textarea height adjusts on input (auto-resize)", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
// After mount, textarea should have style.height set
|
|
// Just verify input event triggers without error (auto-resize runs)
|
|
textarea.value = "Line 1\nLine 2\nLine 3";
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
// Height should be set (not "auto")
|
|
expect(textarea.style.height).not.toBe("");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── Emoji picker ──
|
|
|
|
it("clicking emoji button opens emoji picker", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
|
|
emojiBtn.click();
|
|
|
|
const picker = container.querySelector(".emoji-picker");
|
|
expect(picker).not.toBeNull();
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("selecting emoji inserts it into textarea at cursor position", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "Hello world";
|
|
textarea.selectionStart = 6;
|
|
textarea.selectionEnd = 6;
|
|
|
|
// Open picker
|
|
const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
|
|
emojiBtn.click();
|
|
|
|
expect(lastEmojiPickerOptions).not.toBeNull();
|
|
lastEmojiPickerOptions!.onSelect("🎉");
|
|
|
|
// Emoji should be inserted at cursor position
|
|
expect(textarea.value).toBe("Hello 🎉 world");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── GIF picker ──
|
|
|
|
it("clicking GIF button opens GIF picker", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
|
|
gifBtn.click();
|
|
|
|
const picker = container.querySelector(".gif-picker");
|
|
expect(picker).not.toBeNull();
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("selecting a GIF sends it as a message immediately", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
|
|
gifBtn.click();
|
|
|
|
expect(lastGifPickerOptions).not.toBeNull();
|
|
lastGifPickerOptions!.onSelect("https://media.klipy.com/example.gif");
|
|
|
|
expect(opts.onSend).toHaveBeenCalledWith("https://media.klipy.com/example.gif", null, []);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("selecting a GIF sends it without discarding a typed draft", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "wait for it...";
|
|
|
|
const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
|
|
gifBtn.click();
|
|
expect(lastGifPickerOptions).not.toBeNull();
|
|
lastGifPickerOptions!.onSelect("https://media.klipy.com/example.gif");
|
|
|
|
// The GIF is sent as its own message...
|
|
expect(opts.onSend).toHaveBeenCalledWith("https://media.klipy.com/example.gif", null, []);
|
|
// ...and the user's typed draft survives, instead of being overwritten
|
|
// and thrown away.
|
|
expect(textarea.value).toBe("wait for it...");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("opening GIF picker closes emoji picker", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
// Open emoji picker first
|
|
const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
|
|
emojiBtn.click();
|
|
expect(container.querySelector(".emoji-picker")).not.toBeNull();
|
|
|
|
// Open GIF picker
|
|
const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
|
|
gifBtn.click();
|
|
|
|
// Emoji picker should be removed
|
|
expect(container.querySelector(".emoji-picker")).toBeNull();
|
|
// GIF picker should be present
|
|
expect(container.querySelector(".gif-picker")).not.toBeNull();
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("opening emoji picker closes GIF picker", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
// Open GIF picker first
|
|
const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
|
|
gifBtn.click();
|
|
expect(container.querySelector(".gif-picker")).not.toBeNull();
|
|
|
|
// Open emoji picker
|
|
const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
|
|
emojiBtn.click();
|
|
|
|
// GIF picker should be removed
|
|
expect(container.querySelector(".gif-picker")).toBeNull();
|
|
// Emoji picker should be present
|
|
expect(container.querySelector(".emoji-picker")).not.toBeNull();
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── Destroy cleans up ──
|
|
|
|
it("destroy removes DOM and cleans up pickers", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
// Open a picker so there's state to clean up
|
|
const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
|
|
emojiBtn.click();
|
|
|
|
comp.destroy?.();
|
|
|
|
expect(container.querySelector(".message-input-wrap")).toBeNull();
|
|
});
|
|
|
|
// ── Paste file handling ──
|
|
|
|
it("pasting an image file triggers upload", async () => {
|
|
const onUploadFile = vi.fn(async () => ({
|
|
id: "paste-1",
|
|
url: "http://x",
|
|
filename: "paste.png",
|
|
}));
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
const file = new File(["img"], "paste.png", { type: "image/png" });
|
|
|
|
// ClipboardEvent is not available in jsdom, so create a plain Event and
|
|
// attach clipboardData manually
|
|
const pasteEvent = new Event("paste", { bubbles: true }) as Event & {
|
|
clipboardData?: { items: Array<{ kind: string; type: string; getAsFile(): File | null }> };
|
|
};
|
|
Object.defineProperty(pasteEvent, "clipboardData", {
|
|
value: {
|
|
items: [
|
|
{
|
|
kind: "file",
|
|
type: "image/png",
|
|
getAsFile: () => file,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
textarea.dispatchEvent(pasteEvent);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onUploadFile).toHaveBeenCalledWith(file);
|
|
});
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── Files with empty MIME type are rejected (security hardening) ──
|
|
|
|
it("files with empty MIME type are rejected", async () => {
|
|
const onUploadFile = vi.fn(async () => ({ id: "unk-1", url: "http://x", filename: "data" }));
|
|
const opts = makeOptions({ onUploadFile });
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const noTypeFile = new File(["data"], "mystery", { type: "" });
|
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
Object.defineProperty(fileInput, "files", { value: [noTypeFile], writable: true });
|
|
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
expect(onUploadFile).not.toHaveBeenCalled();
|
|
const error = container.querySelector(".attachment-upload-error");
|
|
expect(error).not.toBeNull();
|
|
expect(error!.textContent).toContain("is not a supported file type");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── Toggling emoji picker closed ──
|
|
|
|
it("clicking emoji button again closes the picker", () => {
|
|
const opts = makeOptions();
|
|
const comp = createMessageInput(opts);
|
|
comp.mount(container);
|
|
|
|
const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
|
|
|
|
// Open
|
|
emojiBtn.click();
|
|
expect(container.querySelector(".emoji-picker")).not.toBeNull();
|
|
|
|
// Close
|
|
emojiBtn.click();
|
|
expect(container.querySelector(".emoji-picker")).toBeNull();
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
// ── GIF affordance degrades when the server has no GIF provider ───────────
|
|
|
|
describe("GIF button degradation", () => {
|
|
it("is enabled when a gifApi is wired", () => {
|
|
const comp = createMessageInput(makeOptions());
|
|
comp.mount(container);
|
|
|
|
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
|
|
expect(gifBtn.hasAttribute("disabled")).toBe(false);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("renders disabled with a visible reason when no gifApi is wired", () => {
|
|
const comp = createMessageInput(makeOptions({ gifApi: undefined }));
|
|
comp.mount(container);
|
|
|
|
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
|
|
expect(gifBtn.hasAttribute("disabled")).toBe(true);
|
|
expect(gifBtn.title).toBe("GIFs are not enabled on this server");
|
|
expect(gifBtn.getAttribute("aria-label")).toContain("not enabled");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("does not open a picker when no gifApi is wired", () => {
|
|
const comp = createMessageInput(makeOptions({ gifApi: undefined }));
|
|
comp.mount(container);
|
|
|
|
(container.querySelector(".gif-btn") as HTMLElement).click();
|
|
expect(container.querySelector(".gif-picker")).toBeNull();
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("disables the GIF button when the picker reports the server has GIFs off", () => {
|
|
const comp = createMessageInput(makeOptions());
|
|
comp.mount(container);
|
|
|
|
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
|
|
gifBtn.click();
|
|
lastGifPickerOptions!.onUnavailable!("GIFs are not enabled on this server");
|
|
|
|
expect(gifBtn.hasAttribute("disabled")).toBe(true);
|
|
expect(gifBtn.title).toBe("GIFs are not enabled on this server");
|
|
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("keeps the GIF button disabled after the composer is re-enabled", () => {
|
|
const comp = createMessageInput(makeOptions({ gifApi: undefined }));
|
|
comp.mount(container);
|
|
|
|
comp.setDisabled("Read-only channel");
|
|
comp.setDisabled(null);
|
|
|
|
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
|
|
expect(gifBtn.hasAttribute("disabled")).toBe(true);
|
|
|
|
comp.destroy?.();
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Formatting shortcuts
|
|
// -------------------------------------------------------------------------
|
|
|
|
describe("formatting shortcuts", () => {
|
|
/** Mount a composer with `value` selected from `start` to `end`. */
|
|
function mountWithSelection(
|
|
value: string,
|
|
start: number,
|
|
end: number,
|
|
): { textarea: HTMLTextAreaElement; destroy: () => void } {
|
|
const comp = createMessageInput(makeOptions());
|
|
comp.mount(container);
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = value;
|
|
textarea.selectionStart = start;
|
|
textarea.selectionEnd = end;
|
|
return { textarea, destroy: () => comp.destroy?.() };
|
|
}
|
|
|
|
function press(textarea: HTMLTextAreaElement, key: string): KeyboardEvent {
|
|
const event = new KeyboardEvent("keydown", {
|
|
key,
|
|
ctrlKey: true,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
textarea.dispatchEvent(event);
|
|
return event;
|
|
}
|
|
|
|
it("Ctrl+B wraps the selection in **", () => {
|
|
const { textarea, destroy } = mountWithSelection("make this bold", 5, 9);
|
|
const event = press(textarea, "b");
|
|
expect(textarea.value).toBe("make **this** bold");
|
|
expect(textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)).toBe("this");
|
|
expect(event.defaultPrevented).toBe(true);
|
|
destroy();
|
|
});
|
|
|
|
it("Ctrl+I wraps the selection in *", () => {
|
|
const { textarea, destroy } = mountWithSelection("hello", 0, 5);
|
|
press(textarea, "i");
|
|
expect(textarea.value).toBe("*hello*");
|
|
destroy();
|
|
});
|
|
|
|
it("Ctrl+U wraps the selection in __ instead of opening the file picker", () => {
|
|
const onUploadFile = vi.fn();
|
|
const comp = createMessageInput(makeOptions({ onUploadFile }));
|
|
comp.mount(container);
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "hello";
|
|
textarea.selectionStart = 0;
|
|
textarea.selectionEnd = 5;
|
|
|
|
// The global Ctrl+U shortcut listens on document — the composer must
|
|
// stop the event before it gets there.
|
|
const globalHandler = vi.fn();
|
|
document.addEventListener("keydown", globalHandler);
|
|
|
|
press(textarea, "u");
|
|
|
|
expect(textarea.value).toBe("__hello__");
|
|
expect(globalHandler).not.toHaveBeenCalled();
|
|
expect(onUploadFile).not.toHaveBeenCalled();
|
|
document.removeEventListener("keydown", globalHandler);
|
|
comp.destroy?.();
|
|
});
|
|
|
|
it("inserts empty markers and parks the caret between them", () => {
|
|
const { textarea, destroy } = mountWithSelection("ab", 1, 1);
|
|
press(textarea, "b");
|
|
expect(textarea.value).toBe("a****b");
|
|
expect(textarea.selectionStart).toBe(3);
|
|
expect(textarea.selectionEnd).toBe(3);
|
|
destroy();
|
|
});
|
|
|
|
it("unwraps an already-bold selection", () => {
|
|
const { textarea, destroy } = mountWithSelection("**this**", 0, 8);
|
|
press(textarea, "b");
|
|
expect(textarea.value).toBe("this");
|
|
destroy();
|
|
});
|
|
|
|
it("leaves other Ctrl combos alone", () => {
|
|
const { textarea, destroy } = mountWithSelection("hello", 0, 5);
|
|
const event = press(textarea, "k");
|
|
expect(textarea.value).toBe("hello");
|
|
expect(event.defaultPrevented).toBe(false);
|
|
destroy();
|
|
});
|
|
|
|
it("does nothing while the composer is disabled", () => {
|
|
const comp = createMessageInput(makeOptions());
|
|
comp.mount(container);
|
|
comp.setDisabled("Read-only channel");
|
|
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
|
|
textarea.value = "hello";
|
|
textarea.selectionStart = 0;
|
|
textarea.selectionEnd = 5;
|
|
press(textarea, "b");
|
|
expect(textarea.value).toBe("hello");
|
|
comp.destroy?.();
|
|
});
|
|
});
|
|
|
|
describe("wrapWithMarker", () => {
|
|
it("wraps a selection and reselects the inner text", () => {
|
|
expect(wrapWithMarker("abc", 1, 2, "~~")).toEqual({
|
|
value: "a~~b~~c",
|
|
selectionStart: 3,
|
|
selectionEnd: 4,
|
|
});
|
|
});
|
|
|
|
it("unwraps markers that surround the selection", () => {
|
|
expect(wrapWithMarker("a**b**c", 3, 4, "**")).toEqual({
|
|
value: "abc",
|
|
selectionStart: 1,
|
|
selectionEnd: 2,
|
|
});
|
|
});
|
|
|
|
it("does not mistake a short selection for a wrapped one", () => {
|
|
expect(wrapWithMarker("**", 0, 2, "**").value).toBe("******");
|
|
});
|
|
|
|
it("wraps rather than mangles a selection spanning multiple already-wrapped spans", () => {
|
|
// The selection starts and ends with "*" but is not itself a single
|
|
// wrapped span — unwrapping it would destroy both interior spans.
|
|
const result = wrapWithMarker("*hello* world *bye*", 0, 19, "*");
|
|
expect(result.value).toBe("**hello* world *bye**");
|
|
});
|
|
|
|
it("wraps rather than downgrades bold text when italicizing", () => {
|
|
const result = wrapWithMarker("**bold**", 0, 8, "*");
|
|
expect(result.value).toBe("***bold***");
|
|
});
|
|
});
|
|
});
|