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();