Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
21 kB · 652 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653import { describe, it, beforeEach, afterEach } from "node:test";import assert from "node:assert/strict";
await import("/js/components/chat-input.js");
describe("chat-input", () => { beforeEach(() => { document.body.innerHTML = ""; });
function createChatInput(attributes = {}) { const element = document.createElement("chat-input"); for (const [name, value] of Object.entries(attributes)) { element.setAttribute(name, value); } document.body.appendChild(element); return element; }
function getRichTextInput(element) { return element.querySelector("rich-text-input"); }
function getEditable(element) { return element.querySelector(".rich-text-input"); }
describe("ChatInput - rendering", () => { it("should render message-input-container", () => { const element = createChatInput(); const container = element.querySelector(".message-input-container"); assert(container !== null); });
it("should render a rich-text-input", () => { const element = createChatInput(); const richTextInput = getRichTextInput(element); assert(richTextInput !== null); assert(getEditable(element) !== null); });
it("should render the rich-text-input with placeholder", () => { const element = createChatInput(); assert.deepEqual( getRichTextInput(element).getAttribute("placeholder"), "Write a message", ); });
it("should render the rich-text-input with upward typeahead", () => { const element = createChatInput(); assert.deepEqual( getRichTextInput(element).getAttribute("typeahead-direction"), "up", ); });
it("should render send button", () => { const element = createChatInput(); const button = element.querySelector(".message-input-send-button"); assert(button !== null); }); });
describe("ChatInput - disabled state", () => { it("should not be disabled by default", () => { const element = createChatInput(); assert.deepEqual(element.disabled, false); });
it("should be disabled when disabled attribute is set", () => { const element = createChatInput({ disabled: "" }); assert.deepEqual(element.disabled, true); });
it("should disable the rich-text-input when disabled", () => { const element = createChatInput({ disabled: "" }); assert.deepEqual( getEditable(element).getAttribute("contenteditable"), "false", ); });
it("should disable send button when disabled", () => { const element = createChatInput({ disabled: "" }); const button = element.querySelector(".message-input-send-button"); assert(button.disabled); });
it("should update disabled state when attribute changes", () => { const element = createChatInput(); assert.deepEqual(element.disabled, false); element.setAttribute("disabled", ""); assert.deepEqual(element.disabled, true); assert.deepEqual( getEditable(element).getAttribute("contenteditable"), "false", ); element.removeAttribute("disabled"); assert.deepEqual(element.disabled, false); assert.deepEqual( getEditable(element).getAttribute("contenteditable"), "true", ); }); });
describe("ChatInput - loading state", () => { it("should not be loading by default", () => { const element = createChatInput(); assert.deepEqual(element.loading, false); });
it("should be loading when loading attribute is set", () => { const element = createChatInput({ loading: "" }); assert.deepEqual(element.loading, true); });
it("should show loading spinner when loading", () => { const element = createChatInput({ loading: "" }); const spinner = element.querySelector(".loading-spinner"); assert(spinner !== null); });
it("should not show loading spinner when not loading", () => { const element = createChatInput(); const spinner = element.querySelector(".loading-spinner"); assert.deepEqual(spinner, null); });
it("should update loading state when attribute changes", () => { const element = createChatInput(); assert.deepEqual(element.loading, false); element.setAttribute("loading", ""); assert.deepEqual(element.loading, true); element.removeAttribute("loading"); assert.deepEqual(element.loading, false); }); });
describe("ChatInput - focus and blur", () => { it("should focus the rich-text-input when focus() is called", () => { const element = createChatInput(); element.focus(); assert.deepEqual(document.activeElement, getEditable(element)); });
it("should blur the rich-text-input when blur() is called", () => { const element = createChatInput(); element.focus(); element.blur(); assert(document.activeElement !== getEditable(element)); }); });
describe("ChatInput - send event", () => { it("should dispatch send event when send button is clicked", () => { const element = createChatInput(); getRichTextInput(element).setText("Hello world");
let receivedMessage = null; element.addEventListener("send", (e) => { receivedMessage = e.detail.message; });
const button = element.querySelector(".message-input-send-button"); button.click();
assert.deepEqual(receivedMessage, "Hello world"); });
it("should clear the input when the send handler reports success", () => { const element = createChatInput(); getRichTextInput(element).setText("Hello world");
element.addEventListener("send", (e) => { e.detail.onSuccess(); });
const button = element.querySelector(".message-input-send-button"); button.click();
assert.deepEqual(getRichTextInput(element).text, ""); assert.deepEqual(element.messageText, ""); });
it("should keep the input text when the send handler does not report success", () => { const element = createChatInput(); getRichTextInput(element).setText("Hello world");
const button = element.querySelector(".message-input-send-button"); button.click();
assert.deepEqual(getRichTextInput(element).text, "Hello world"); assert.deepEqual(element.messageText, "Hello world"); });
it("should not dispatch send event when message is empty", () => { const element = createChatInput();
let eventFired = false; element.addEventListener("send", () => { eventFired = true; });
const button = element.querySelector(".message-input-send-button"); button.click();
assert.deepEqual(eventFired, false); });
it("should not dispatch send event when message is only whitespace", () => { const element = createChatInput(); getRichTextInput(element).setText(" ");
let eventFired = false; element.addEventListener("send", () => { eventFired = true; });
const button = element.querySelector(".message-input-send-button"); button.click();
assert.deepEqual(eventFired, false); });
it("should not dispatch send event when loading", () => { const element = createChatInput(); getRichTextInput(element).setText("Hello world"); element.setAttribute("loading", "");
let eventFired = false; element.addEventListener("send", () => { eventFired = true; });
const button = element.querySelector(".message-input-send-button"); button.click();
assert.deepEqual(eventFired, false); });
it("should not dispatch send event when disabled", () => { const element = createChatInput(); getRichTextInput(element).setText("Hello world"); element.setAttribute("disabled", "");
let eventFired = false; element.addEventListener("send", () => { eventFired = true; });
element.handleSend();
assert.deepEqual(eventFired, false); });
it("should trim message before sending", () => { const element = createChatInput(); getRichTextInput(element).setText(" Hello world ");
let receivedMessage = null; element.addEventListener("send", (e) => { receivedMessage = e.detail.message; });
const button = element.querySelector(".message-input-send-button"); button.click();
assert.deepEqual(receivedMessage, "Hello world"); }); });
describe("ChatInput - message length limit", () => { function sendButton(element) { return element.querySelector(".message-input-send-button"); }
it("should disable the send button when the text exceeds 1000 graphemes", () => { const element = createChatInput(); getRichTextInput(element).setText("a".repeat(1001)); assert(sendButton(element).disabled); });
it("should count graphemes rather than code units", () => { const element = createChatInput(); // 1000 graphemes but 2000 code units — within the lexicon limits getRichTextInput(element).setText("👍".repeat(1000)); assert.deepEqual(sendButton(element).disabled, false); });
it("should disable the send button when the text exceeds 10000 bytes", () => { const element = createChatInput(); // 500 graphemes, but 25 UTF-8 bytes each (12500 bytes total) getRichTextInput(element).setText("👨👩👧👦".repeat(500)); assert(sendButton(element).disabled); });
it("should not dispatch send while over the limit", () => { const element = createChatInput(); getRichTextInput(element).setText("a".repeat(1001));
let eventFired = false; element.addEventListener("send", () => { eventFired = true; });
element.handleSend();
assert.deepEqual(eventFired, false); });
it("should re-enable the send button when the text shrinks below the limit", () => { const element = createChatInput(); getRichTextInput(element).setText("a".repeat(1001)); getRichTextInput(element).setText("short again"); assert.deepEqual(sendButton(element).disabled, false); }); });
describe("ChatInput - send button disabled state", () => { function sendButton(element) { return element.querySelector(".message-input-send-button"); }
it("should disable the send button when there is nothing to send", () => { const element = createChatInput(); assert(sendButton(element).disabled); });
it("should enable the send button once text is entered", () => { const element = createChatInput(); getRichTextInput(element).setText("hello"); assert.deepEqual(sendButton(element).disabled, false); });
it("should keep the send button disabled for whitespace-only text", () => { const element = createChatInput(); getRichTextInput(element).setText(" "); assert(sendButton(element).disabled); });
it("should disable the send button again when the text is cleared", () => { const element = createChatInput(); getRichTextInput(element).setText("hello"); getRichTextInput(element).setText(""); assert(sendButton(element).disabled); });
it("should enable the send button for an embed with no text", () => { const element = createChatInput(); assert(sendButton(element).disabled); element.setAttribute("has-embed", ""); assert.deepEqual(sendButton(element).disabled, false); element.removeAttribute("has-embed"); assert(sendButton(element).disabled); }); });
describe("ChatInput - embed-only send", () => { it("should dispatch send with an empty message when has-embed is set", () => { const element = createChatInput({ "has-embed": "" });
let receivedMessage = null; element.addEventListener("send", (e) => { receivedMessage = e.detail.message; });
const button = element.querySelector(".message-input-send-button"); button.click();
assert.deepEqual(receivedMessage, ""); }); });
describe("ChatInput - input-change event", () => { function setup() { const element = createChatInput(); const events = []; element.addEventListener("input-change", (e) => { events.push(e.detail); }); return { element, events }; }
it("emits the current text on input", () => { const { element, events } = setup(); const editable = getEditable(element); editable.textContent = "hello world"; editable.dispatchEvent(new window.InputEvent("input"));
assert.deepEqual(events.length, 1); assert.deepEqual(events[0].text, "hello world"); assert.deepEqual(events[0].inputType, null); });
it("reports insertFromPaste for pasted input", () => { const { element, events } = setup(); const editable = getEditable(element);
const originalExecCommand = document.execCommand; // Like the browser, insert the text and fire input synchronously document.execCommand = (name, _ui, value) => { editable.textContent = editable.textContent + value; editable.dispatchEvent(new window.InputEvent("input")); return true; }; try { const pasteEvent = new window.Event("paste", { bubbles: true, cancelable: true, }); pasteEvent.clipboardData = { getData: () => "pasted text" }; editable.dispatchEvent(pasteEvent); } finally { document.execCommand = originalExecCommand; }
editable.textContent = "pasted text!"; editable.dispatchEvent(new window.InputEvent("input"));
assert.deepEqual(events.length, 2); assert.deepEqual(events[0].text, "pasted text"); assert.deepEqual(events[0].inputType, "insertFromPaste"); assert.deepEqual(events[1].inputType, null); }); });
describe("ChatInput - keyboard handling", () => { let originalMatchMedia; beforeEach(() => { originalMatchMedia = window.matchMedia; window.matchMedia = (query) => ({ matches: query === "(hover: hover) and (pointer: fine)", media: query, onchange: null, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => {}, }); }); afterEach(() => { window.matchMedia = originalMatchMedia; });
function enterEvent(options = {}) { return new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...options, }); }
it("should send message on Enter key", () => { const element = createChatInput(); getRichTextInput(element).setText("Hello world");
let receivedMessage = null; element.addEventListener("send", (e) => { receivedMessage = e.detail.message; });
getEditable(element).dispatchEvent(enterEvent());
assert.deepEqual(receivedMessage, "Hello world"); });
it("should not send message on Enter without a keyboard input device", () => { window.matchMedia = (query) => ({ matches: false, media: query, onchange: null, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => {}, });
const element = createChatInput(); getRichTextInput(element).setText("Hello world");
let eventFired = false; element.addEventListener("send", () => { eventFired = true; });
getEditable(element).dispatchEvent(enterEvent());
assert.deepEqual(eventFired, false); });
it("should not send message on Shift+Enter", () => { const element = createChatInput(); getRichTextInput(element).setText("Hello world");
let eventFired = false; element.addEventListener("send", () => { eventFired = true; });
getEditable(element).dispatchEvent(enterEvent({ shiftKey: true }));
assert.deepEqual(eventFired, false); });
it("should select a mention instead of sending when the typeahead is open", () => { const element = createChatInput(); const richTextInput = getRichTextInput(element); richTextInput.setText("Hi @al"); richTextInput.currentMentionStart = 3; richTextInput.currentMentionEnd = 6; richTextInput.mentionSuggestions = [{ handle: "alice.bsky.social" }];
let sendFired = false; element.addEventListener("send", () => { sendFired = true; });
const execCommandCalls = []; const originalExecCommand = document.execCommand; document.execCommand = (name, _ui, value) => { execCommandCalls.push({ name, value }); return true; }; try { getEditable(element).dispatchEvent(enterEvent()); } finally { document.execCommand = originalExecCommand; }
assert.deepEqual(sendFired, false); assert.deepEqual(execCommandCalls.length, 1); assert.deepEqual(execCommandCalls[0].value, "@alice.bsky.social "); });
it("should send on Enter after the typeahead was dismissed with Escape", () => { const element = createChatInput(); const richTextInput = getRichTextInput(element); richTextInput.setText("Hi @al"); richTextInput.mentionSuggestions = [{ handle: "alice.bsky.social" }];
let receivedMessage = null; element.addEventListener("send", (e) => { receivedMessage = e.detail.message; });
getEditable(element).dispatchEvent( new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true, }), ); assert.deepEqual(receivedMessage, null);
getEditable(element).dispatchEvent(enterEvent()); assert.deepEqual(receivedMessage, "Hi @al"); }); });
describe("ChatInput - emoji insertion", () => { it("captures the caret before opening the emoji picker", () => { const element = createChatInput(); const richTextInput = getRichTextInput(element); richTextInput.setText("hello"); richTextInput.getCursor = () => 2;
const dialog = element.querySelector("emoji-picker-dialog"); dialog.open = () => {};
const button = element.querySelector(".message-input-emoji-button"); element.handleEmojiButtonClick({ currentTarget: button });
assert.deepEqual(element._emojiCursor, 2); });
it("inserts the emoji at the captured caret position", () => { const element = createChatInput(); const richTextInput = getRichTextInput(element); richTextInput.setText("hello"); element._emojiCursor = 2;
element.handleEmojiSelect("🎉");
assert.deepEqual(richTextInput.text, "he🎉llo"); assert.deepEqual(element.messageText, "he🎉llo"); });
it("appends the emoji when no caret was captured", () => { const element = createChatInput(); const richTextInput = getRichTextInput(element); richTextInput.setText("hello"); element._emojiCursor = null;
element.handleEmojiSelect("🎉");
assert.deepEqual(richTextInput.text, "hello🎉"); }); });
describe("ChatInput - height reporting", () => { it("dispatches height-change when the reported height changes", () => { const element = createChatInput();
let reportedHeight = null; element.addEventListener("height-change", (e) => { reportedHeight = e.detail.height; });
Object.defineProperty(element, "offsetHeight", { get: () => 42, configurable: true, }); element.reportHeight();
assert.deepEqual(reportedHeight, 42); });
it("does not dispatch height-change when the height is unchanged", () => { const element = createChatInput(); Object.defineProperty(element, "offsetHeight", { get: () => 42, configurable: true, }); element.reportHeight();
let eventFired = false; element.addEventListener("height-change", () => { eventFired = true; }); element.reportHeight();
assert.deepEqual(eventFired, false); }); });
describe("ChatInput - reinitialization protection", () => { it("should not reinitialize when connectedCallback is called multiple times", () => { const element = createChatInput(); getRichTextInput(element).setText("Test message");
element.connectedCallback();
assert.deepEqual(getRichTextInput(element).text, "Test message"); assert.deepEqual(element.messageText, "Test message"); }); });});