diff --git a/package.json b/package.json index de5f02e3..189e3fb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.17.7", + "version": "0.17.8", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 5212c208..c6260b82 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -4001,12 +4001,10 @@ chat-input { line-height: 1; } -emoji-picker { +emoji-picker-dialog emoji-picker { --num-columns: 6; --category-emoji-size: 1.125rem; - - position: absolute; - bottom: 100%; + --input-font-size: 16px; } .chat-icon.filled circle { diff --git a/src/js/components/emoji-picker-dialog.js b/src/js/components/emoji-picker-dialog.js new file mode 100644 index 00000000..9280c6ac --- /dev/null +++ b/src/js/components/emoji-picker-dialog.js @@ -0,0 +1,88 @@ +import { Component } from "/js/components/component.js"; +import { ScrollLock } from "/js/scrollLock.js"; +import "/js/lib/emoji-picker-element.js"; + +class EmojiPickerDialog extends Component { + connectedCallback() { + if (this._initialized) { + return; + } + this.scrollLock = new ScrollLock(this); + this.isOpen = false; + this.addEventListener("emoji-click", this._handleEmojiClick); + this.addEventListener("click", (event) => event.stopPropagation()); + this._initialized = true; + } + + disconnectedCallback() { + this.close(); + } + + open() { + if (this.isOpen) { + return; + } + const picker = document.createElement("emoji-picker"); + this.appendChild(picker); + this.scrollLock.lock(); + this.isOpen = true; + this._picker = picker; + this._positionPicker(); + this._reposition = () => this._positionPicker(); + window.addEventListener("resize", this._reposition); + } + + close() { + if (!this.isOpen) { + return; + } + window.removeEventListener("resize", this._reposition); + this._reposition = null; + this._picker = null; + this.innerHTML = ""; + this.scrollLock.unlock(); + this.isOpen = false; + } + + // Position the picker as a fixed-viewport overlay so it can't fall off + // small screens. Prefers placing it above the dialog (the previous default) + // and flips below when there isn't room. + _positionPicker() { + const picker = this._picker; + if (!picker) { + return; + } + const margin = 8; + const anchor = this.getBoundingClientRect(); + const pickerRect = picker.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let top = anchor.top - pickerRect.height - margin; + if (top < margin) { + const below = anchor.bottom + margin; + top = + below + pickerRect.height <= viewportHeight - margin + ? below + : Math.max(margin, viewportHeight - pickerRect.height - margin); + } + + let left = anchor.left; + if (left + pickerRect.width > viewportWidth - margin) { + left = Math.max(margin, viewportWidth - pickerRect.width - margin); + } + + picker.style.position = "fixed"; + picker.style.top = `${top}px`; + picker.style.left = `${left}px`; + picker.style.bottom = "auto"; + } + + _handleEmojiClick = (event) => { + this.dispatchEvent( + new CustomEvent("select", { detail: { emoji: event.detail.unicode } }), + ); + }; +} + +EmojiPickerDialog.register(); diff --git a/src/js/scrollLock.js b/src/js/scrollLock.js index d46b1bcb..62e04142 100644 --- a/src/js/scrollLock.js +++ b/src/js/scrollLock.js @@ -68,12 +68,35 @@ function unlockScroll(container) { window.scrollTo(0, scrollTo); } +function findScrollableAncestor(element) { + let current = element.parentElement; + while ( + current && + current !== document.body && + current !== document.documentElement + ) { + const style = window.getComputedStyle(current); + const overflowY = style.overflowY; + if ( + (overflowY === "auto" || overflowY === "scroll") && + current.scrollHeight > current.clientHeight + ) { + return current; + } + current = current.parentElement; + } + return null; +} + let __activeScrollLock = null; export class ScrollLock { - constructor() { + constructor(target) { + this.target = target ?? null; this.container = document.querySelector(".page-visible"); // todo find better way to get container this.locked = false; + this._lockedAncestor = null; + this._previousAncestorOverflow = ""; } lock() { @@ -85,6 +108,13 @@ export class ScrollLock { return; } lockScroll(this.container); + // If target is passed, lock the nearest scrollable ancestor of that target in addition to the outer page + const ancestor = this.target ? findScrollableAncestor(this.target) : null; + if (ancestor) { + this._lockedAncestor = ancestor; + this._previousAncestorOverflow = ancestor.style.overflow; + ancestor.style.overflow = "hidden"; + } this.locked = true; __activeScrollLock = this; } @@ -93,6 +123,11 @@ export class ScrollLock { if (!this.locked) { return; } + if (this._lockedAncestor) { + this._lockedAncestor.style.overflow = this._previousAncestorOverflow; + this._lockedAncestor = null; + this._previousAncestorOverflow = ""; + } unlockScroll(this.container); this.locked = false; __activeScrollLock = null; diff --git a/src/js/views/chatDetail.view.js b/src/js/views/chatDetail.view.js index 4909805e..7ca3027a 100644 --- a/src/js/views/chatDetail.view.js +++ b/src/js/views/chatDetail.view.js @@ -21,7 +21,9 @@ import { hapticsImpactMedium } from "/js/haptics.js"; import { getPermalinkForConvo } from "/js/navigation.js"; import "/js/components/infinite-scroll-container.js"; import "/js/components/chat-input.js"; -import "/js/lib/emoji-picker-element.js"; +import "/js/components/emoji-picker-dialog.js"; +import "/js/components/context-menu.js"; +import "/js/components/context-menu-item.js"; class ChatDetailView extends View { async render({ @@ -392,23 +394,21 @@ class ChatDetailView extends View { + { + handleEmojiSelect(e.detail.emoji, message.id, currentUserDid); + }} + > `; } diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index 5edbe3d9..01bb3a14 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -250,6 +250,28 @@ export class MockServer { } async setup(page) { + // Stub emoji-picker-element's CDN data fetch so tests don't hit the + // network. Returns a tiny fixture with one emoji per group the library + // requires (its assertEmojiData rejects an empty list). + await page.route("**/emoji-picker-element-data@*/**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { etag: '"test-emoji-data"' }, + body: JSON.stringify([ + { + annotation: "party popper", + emoji: "🎉", + group: 0, + order: 1, + version: 0.6, + shortcodes: ["tada"], + tags: ["celebration"], + }, + ]), + }), + ); + // Plugin fixture routes — serve a self-contained test plugin so plugin // e2e tests don't depend on plugins-local/. await page.route("**/plugins-local/index.json", (route) => diff --git a/tests/e2e/specs/views/chatDetail.view.test.js b/tests/e2e/specs/views/chatDetail.view.test.js index 1151206e..bbacfcbb 100644 --- a/tests/e2e/specs/views/chatDetail.view.test.js +++ b/tests/e2e/specs/views/chatDetail.view.test.js @@ -391,6 +391,174 @@ test.describe("Chat detail view", () => { }); }); + test("should open the emoji picker from the reaction palette more button", async ({ + page, + }) => { + const mockServer = new MockServer(); + const alice = createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const convo = createConvo({ + id: "convo-1", + otherMember: alice, + }); + const messages = [ + createMessage({ + id: "msg-1", + text: "Hey there!", + senderDid: alice.did, + sentAt: "2025-01-15T12:00:00.000Z", + }), + ]; + mockServer.addConvos([convo]); + mockServer.addConvoMessages("convo-1", messages); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/convo-1"); + + const chatDetailView = page.locator("#chat-detail-view"); + await expect(chatDetailView.locator(".message-bubble")).toHaveCount(1, { + timeout: 10000, + }); + + const messageEl = chatDetailView.locator(".message").first(); + const box = await messageEl.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.waitForTimeout(600); + await page.mouse.up(); + + await expect(chatDetailView.locator(".reaction-palette")).toBeVisible({ + timeout: 5000, + }); + + // No picker mounted before the more button is clicked + await expect(chatDetailView.locator("emoji-picker")).toHaveCount(0); + + await chatDetailView.locator(".reaction-palette-button-more").click(); + + // Picker appears inside the dialog + await expect( + chatDetailView.locator("emoji-picker-dialog emoji-picker"), + ).toHaveCount(1, { timeout: 5000 }); + }); + + test("should add a reaction when an emoji is selected from the picker", async ({ + page, + }) => { + const mockServer = new MockServer(); + const alice = createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const convo = createConvo({ + id: "convo-1", + otherMember: alice, + }); + const messages = [ + createMessage({ + id: "msg-1", + text: "Hey there!", + senderDid: alice.did, + sentAt: "2025-01-15T12:00:00.000Z", + }), + ]; + mockServer.addConvos([convo]); + mockServer.addConvoMessages("convo-1", messages); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/convo-1"); + + const chatDetailView = page.locator("#chat-detail-view"); + await expect(chatDetailView.locator(".message-bubble")).toHaveCount(1, { + timeout: 10000, + }); + + const messageEl = chatDetailView.locator(".message").first(); + const box = await messageEl.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.waitForTimeout(600); + await page.mouse.up(); + + await expect(chatDetailView.locator(".reaction-palette")).toBeVisible({ + timeout: 5000, + }); + + await chatDetailView.locator(".reaction-palette-button-more").click(); + + const picker = chatDetailView.locator("emoji-picker-dialog emoji-picker"); + await expect(picker).toHaveCount(1, { timeout: 5000 }); + + // Click the emoji from the picker's grid. The data fixture lives in + // MockServer; Playwright pierces the picker's shadow DOM automatically. + await picker.locator('button.emoji[aria-label*="party popper"]').click(); + + await expect(chatDetailView.locator(".reaction-bubble")).toHaveCount(1, { + timeout: 5000, + }); + await expect(chatDetailView.locator(".reaction-emoji")).toContainText("🎉"); + }); + + test("should toggle the emoji picker closed when the more button is clicked twice", async ({ + page, + }) => { + const mockServer = new MockServer(); + const alice = createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + }); + const convo = createConvo({ + id: "convo-1", + otherMember: alice, + }); + const messages = [ + createMessage({ + id: "msg-1", + text: "Hey there!", + senderDid: alice.did, + sentAt: "2025-01-15T12:00:00.000Z", + }), + ]; + mockServer.addConvos([convo]); + mockServer.addConvoMessages("convo-1", messages); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages/convo-1"); + + const chatDetailView = page.locator("#chat-detail-view"); + await expect(chatDetailView.locator(".message-bubble")).toHaveCount(1, { + timeout: 10000, + }); + + const messageEl = chatDetailView.locator(".message").first(); + const box = await messageEl.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.waitForTimeout(600); + await page.mouse.up(); + + await expect(chatDetailView.locator(".reaction-palette")).toBeVisible({ + timeout: 5000, + }); + + const moreButton = chatDetailView.locator(".reaction-palette-button-more"); + await moreButton.click(); + await expect(chatDetailView.locator("emoji-picker")).toHaveCount(1, { + timeout: 5000, + }); + + await moreButton.click(); + await expect(chatDetailView.locator("emoji-picker")).toHaveCount(0); + }); + test.describe("Group conversations", () => { const alice = createProfile({ did: "did:plc:alice1", diff --git a/tests/unit/specs/components/emoji-picker-dialog.test.js b/tests/unit/specs/components/emoji-picker-dialog.test.js new file mode 100644 index 00000000..6198198b --- /dev/null +++ b/tests/unit/specs/components/emoji-picker-dialog.test.js @@ -0,0 +1,120 @@ +import { TestSuite } from "../../testSuite.js"; +import { assert, assertEquals } from "../../testHelpers.js"; + +// The emoji-picker-element library reads `Element.prototype` at module load, +// so expose JSDOM's Element on the global before the dialog module is imported. +globalThis.Element = globalThis.window.Element; + +// Pre-register a stub `emoji-picker` so the real library does not try to +// define itself (it depends on browser features JSDOM doesn't implement). +class EmojiPickerStub extends globalThis.window.HTMLElement {} +globalThis.customElements.define("emoji-picker", EmojiPickerStub); + +await import("/js/components/emoji-picker-dialog.js"); + +const t = new TestSuite("EmojiPickerDialog"); + +function connectElement(element) { + const container = document.createElement("div"); + container.className = "page-visible"; + container.appendChild(element); + document.body.appendChild(container); + return container; +} + +t.beforeEach(() => { + document.body.innerHTML = ""; +}); + +t.describe("EmojiPickerDialog - initial state", (it) => { + it("should start closed with no picker child", () => { + const element = document.createElement("emoji-picker-dialog"); + connectElement(element); + assertEquals(element.isOpen, false); + assertEquals(element.querySelector("emoji-picker"), null); + }); +}); + +t.describe("EmojiPickerDialog - open / close", (it) => { + it("should append an emoji-picker child and flip isOpen on open()", () => { + const element = document.createElement("emoji-picker-dialog"); + connectElement(element); + element.open(); + assertEquals(element.isOpen, true); + assert(element.querySelector("emoji-picker") !== null); + }); + + it("should remove the picker and flip isOpen on close()", () => { + const element = document.createElement("emoji-picker-dialog"); + connectElement(element); + element.open(); + element.close(); + assertEquals(element.isOpen, false); + assertEquals(element.querySelector("emoji-picker"), null); + }); + + it("should be a no-op when open() is called twice in a row", () => { + const element = document.createElement("emoji-picker-dialog"); + connectElement(element); + element.open(); + element.open(); + assertEquals(element.querySelectorAll("emoji-picker").length, 1); + }); +}); + +t.describe("EmojiPickerDialog - emoji-click forwarding", (it) => { + it("should re-dispatch emoji-click as a 'select' event with the unicode", () => { + const element = document.createElement("emoji-picker-dialog"); + connectElement(element); + element.open(); + + let received = null; + element.addEventListener("select", (event) => { + received = event.detail; + }); + + const picker = element.querySelector("emoji-picker"); + picker.dispatchEvent( + new CustomEvent("emoji-click", { + detail: { unicode: "🎉" }, + bubbles: true, + }), + ); + + assert(received !== null, "select event should fire"); + assertEquals(received.emoji, "🎉"); + }); + + it("should stop click propagation past the dialog", () => { + const element = document.createElement("emoji-picker-dialog"); + const container = connectElement(element); + element.open(); + + let containerClicked = false; + container.addEventListener("click", () => { + containerClicked = true; + }); + + element + .querySelector("emoji-picker") + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + + assertEquals(containerClicked, false); + }); +}); + +t.describe("EmojiPickerDialog - disconnection cleanup", (it) => { + it("should close (remove picker, clear isOpen) when removed from the DOM", () => { + const element = document.createElement("emoji-picker-dialog"); + const container = connectElement(element); + element.open(); + assertEquals(element.isOpen, true); + + container.removeChild(element); + + assertEquals(element.isOpen, false); + assertEquals(element.querySelector("emoji-picker"), null); + }); +}); + +await t.run();