diff --git a/package.json b/package.json index 90bbf77e..888f0690 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.17.69", + "version": "0.17.70", "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 01f89be0..6a717cd0 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -4097,32 +4097,6 @@ image-carousel { height: 18px; } -.join-group-chat-dialog .join-group-chat-dialog-content { - padding: 20px; - display: flex; - flex-direction: column; - gap: 16px; -} - -.join-group-chat-dialog-title { - font-size: 18px; - font-weight: 700; - margin: 0; -} - -.join-group-chat-dialog-body { - font-size: 16px; - color: var(--text-color); - margin: 0; - line-height: 1.4; -} - -.join-group-chat-dialog-actions { - display: flex; - justify-content: flex-end; - gap: 8px; -} - .message-emoji-trigger { background: transparent; border: none; @@ -5617,6 +5591,24 @@ emoji-picker-dialog emoji-picker, animation: fade-in 0.2s ease-out; } +.bottom-sheet.action-modal { + color: var(--text-color); +} + +.bottom-sheet.action-modal .modal-dialog-content { + padding: 32px 24px 24px; +} + +@media (min-width: 800px) { + .bottom-sheet.action-modal { + max-width: 410px; + width: 90%; + } + .bottom-sheet.action-modal .modal-dialog-content { + padding: 24px; + } +} + .post-composer-top-bar { display: flex; justify-content: space-between; @@ -9362,8 +9354,22 @@ p.setting-item-desc { opacity: 0.75; } -.modal-dialog.confirm-modal { - max-width: 320px; +.bottom-sheet.confirm-modal { + color: var(--text-color); +} + +.bottom-sheet.confirm-modal .modal-dialog-content { + padding: 32px 24px 24px; +} + +@media (min-width: 800px) { + .bottom-sheet.confirm-modal { + max-width: 320px; + width: 90%; + } + .bottom-sheet.confirm-modal .modal-dialog-content { + padding: 24px; + } } .sidebar-action-items { diff --git a/src/js/components/join-group-chat-dialog.js b/src/js/components/join-group-chat-dialog.js deleted file mode 100644 index 893223c6..00000000 --- a/src/js/components/join-group-chat-dialog.js +++ /dev/null @@ -1,139 +0,0 @@ -import { html, render } from "/js/lib/lit-html.js"; -import { Component } from "/js/components/component.js"; -import { ScrollLock } from "/js/scrollLock.js"; -import { enableDragToDismiss } from "/js/utils.js"; -import { Signal, ReactiveStore, effect } from "/js/signals.js"; - -class JoinGroupChatDialog extends Component { - static get observedAttributes() { - return ["name", "require-approval"]; - } - - connectedCallback() { - if (this.initialized) return; - this.setAttribute("data-dialog-wrapper", ""); - this.scrollLock = new ScrollLock(this); - this.state = new ReactiveStore("join-group-chat-dialog"); - this.state.$isOpen = new Signal.State(false); - this.state.$isSubmitting = new Signal.State(false); - this.innerHTML = ""; - this._disposeEffect = effect(() => this.render()); - this.initialized = true; - } - - disconnectedCallback() { - this._disposeEffect?.(); - this._disposeEffect = null; - } - - attributeChangedCallback() { - if (this.initialized) this.render(); - } - - render() { - const isOpen = this.state.$isOpen.get(); - const isSubmitting = this.state.$isSubmitting.get(); - if (!isOpen) { - render(html``, this); - return; - } - const name = this.getAttribute("name") ?? ""; - const requireApproval = this.hasAttribute("require-approval"); - render( - html` { - if (event.target.tagName === "DIALOG") this.close(); - }} - @cancel=${(event) => { - event.preventDefault(); - this.close(); - }} - > -
-

- ${requireApproval ? "Request to join" : "Join group chat"} -

-

- ${requireApproval - ? html`Send a request to join ${name}. The group - owner will review your request before you can see messages.` - : html`You're about to join ${name}.`} -

-
- - -
-
-
`, - this, - ); - } - - _onConfirm() { - if (this.state.$isSubmitting.get()) return; - this.state.$isSubmitting.set(true); - this.dispatchEvent( - new CustomEvent("confirm", { - detail: { - successCallback: () => { - this.state.$isSubmitting.set(false); - this.close(); - }, - errorCallback: () => { - this.state.$isSubmitting.set(false); - }, - }, - }), - ); - } - - open() { - this.state.$isOpen.set(true); - this.state.$isSubmitting.set(false); - this.render(); - this.scrollLock.lock(); - const dialog = this.querySelector("dialog"); - if (!dialog) return; - dialog.showModal(); - enableDragToDismiss(dialog, { - onClose: () => this.close(), - confirmDismiss: () => !this.state.$isSubmitting.get(), - allowUpwardStretch: true, - ignoreTouchTarget: (element) => element.closest("button") !== null, - }); - } - - close() { - if (this.state.$isSubmitting.get()) return; - this.scrollLock.unlock(); - const dialog = this.querySelector("dialog"); - if (dialog?.open) dialog.close(); - this.state.$isOpen.set(false); - this.dispatchEvent(new CustomEvent("dialog-closed")); - } -} - -JoinGroupChatDialog.register(); diff --git a/src/js/groupChatLinkService.js b/src/js/groupChatLinkService.js index fedd92aa..ca093c72 100644 --- a/src/js/groupChatLinkService.js +++ b/src/js/groupChatLinkService.js @@ -1,4 +1,5 @@ -import "/js/components/join-group-chat-dialog.js"; +import { html } from "/js/lib/lit-html.js"; +import { showActionModal, showInfoModal } from "/js/modals.js"; import { showToast } from "/js/toasts.js"; export class GroupChatLinkService { @@ -30,36 +31,29 @@ export class GroupChatLinkService { return; } if (actionType === "requested") { - showToast("Request pending — the group owner will review it.", { - style: "default", + showInfoModal({ + title: "Request pending", + message: "The group owner will review your request.", }); } } _openJoinDialog(preview) { - if (this.currentDialog) { - console.warn("Join group chat dialog already open"); - return; - } - const dialog = document.createElement("join-group-chat-dialog"); - dialog.setAttribute("name", preview.name ?? ""); - if (preview.requireApproval) dialog.setAttribute("require-approval", ""); - dialog.addEventListener("confirm", (event) => - this._submit({ preview, ...event.detail }), - ); - dialog.addEventListener("dialog-closed", () => { - dialog.remove(); - this.currentDialog = null; + const name = preview.name ?? ""; + showActionModal({ + title: preview.requireApproval ? "Request to join" : "Join group chat", + message: preview.requireApproval + ? html`Send a request to join ${name}. The group owner + will review your request before you can see messages.` + : html`You're about to join ${name}.`, + confirmButtonText: preview.requireApproval ? "Send request" : "Join", + onConfirm: () => this._submit(preview), }); - this.currentDialog = dialog; - document.body.appendChild(dialog); - dialog.open(); } - async _submit({ preview, successCallback, errorCallback }) { + async _submit(preview) { try { await this.dataLayer.mutations.requestJoinGroupChat(preview.code); - successCallback(); showToast( preview.requireApproval ? "Request sent — the group owner will review your request." @@ -68,10 +62,10 @@ export class GroupChatLinkService { ); } catch (error) { console.error(error); - errorCallback(); showToast("Could not send join request. Please try again.", { style: "error", }); + throw error; } } } diff --git a/src/js/modals.js b/src/js/modals.js index a18f867a..23232183 100644 --- a/src/js/modals.js +++ b/src/js/modals.js @@ -1,6 +1,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { getThreadgateAllowSettings } from "/js/dataHelpers.js"; import { linkToProfile, linkToLogin } from "/js/navigation.js"; +import { enableDragToDismiss } from "/js/utils.js"; export function showSignInModal() { const dialog = document.createElement("dialog"); @@ -106,7 +107,7 @@ export async function confirm( ) { return new Promise((resolve) => { const dialog = document.createElement("dialog"); - dialog.classList.add("modal-dialog", "confirm-modal"); + dialog.classList.add("bottom-sheet", "confirm-modal"); render( html` @@ -165,12 +166,112 @@ export async function confirm( document.body.appendChild(dialog); dialog.showModal(); + enableDragToDismiss(dialog, { + onClose: () => dismiss(false), + ignoreTouchTarget: (element) => element.closest("button") !== null, + }); // Allow tests to resolve externally globalThis.__testConfirmation?.(resolve); }); } +export async function showActionModal({ + title = null, + message, + confirmButtonText = "Confirm", + confirmButtonStyle = "primary", + onConfirm, +}) { + return new Promise((resolve) => { + const dialog = document.createElement("dialog"); + dialog.classList.add("bottom-sheet", "action-modal"); + + let isPending = false; + + const renderContents = () => { + render( + html` + + `, + dialog, + ); + }; + + const dismiss = (result) => { + if (isPending) return; + dialog.close(); + dialog.remove(); + resolve(result); + }; + + const runConfirm = async () => { + if (isPending) return; + isPending = true; + renderContents(); + try { + await onConfirm?.(); + dialog.close(); + dialog.remove(); + resolve(true); + } catch { + isPending = false; + renderContents(); + } + }; + + renderContents(); + + dialog.addEventListener("click", (event) => { + if (event.target.tagName === "DIALOG") dismiss(false); + }); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + dismiss(false); + }); + + document.body.appendChild(dialog); + dialog.showModal(); + enableDragToDismiss(dialog, { + onClose: () => dismiss(false), + confirmDismiss: () => !isPending, + ignoreTouchTarget: (element) => element.closest("button") !== null, + }); + }); +} + function ruleTemplate({ rule, author }) { if (rule.type === "mention") { return html`mentioned users`; diff --git a/tests/e2e/specs/concerns/postEmbeds.test.js b/tests/e2e/specs/concerns/postEmbeds.test.js index 2759b2df..3c869326 100644 --- a/tests/e2e/specs/concerns/postEmbeds.test.js +++ b/tests/e2e/specs/concerns/postEmbeds.test.js @@ -643,7 +643,7 @@ test.describe("Post embeds view — feed generator / list", () => { .locator('[data-testid="join-link-embed-action"]') .click({ timeout: 10000 }); await expect( - page.locator('[data-testid="join-group-chat-dialog"]'), + page.locator('[data-testid="modal-confirm-button"]'), ).toBeVisible(); }); diff --git a/tests/e2e/specs/flows/joinLinkEmbed.test.js b/tests/e2e/specs/flows/joinLinkEmbed.test.js index 436327a4..c18eac32 100644 --- a/tests/e2e/specs/flows/joinLinkEmbed.test.js +++ b/tests/e2e/specs/flows/joinLinkEmbed.test.js @@ -221,17 +221,15 @@ test.describe("Join link embed flows", () => { await page.locator('[data-testid="join-link-embed-action"]').click(); await expect( - page.locator('[data-testid="join-group-chat-dialog"]'), + page.locator('[data-testid="modal-confirm-button"]'), ).toBeVisible(); - await page - .locator('[data-testid="join-group-chat-dialog-confirm"]') - .click(); + await page.locator('[data-testid="modal-confirm-button"]').click(); await expect(page.locator('[data-testid="toast"]')).toContainText( "Request sent", ); await expect( - page.locator('[data-testid="join-group-chat-dialog"]'), + page.locator('[data-testid="modal-confirm-button"]'), ).toHaveCount(0); }); @@ -253,7 +251,7 @@ test.describe("Join link embed flows", () => { }); await action.click(); - await expect(page.locator('[data-testid="toast"]')).toContainText( + await expect(page.locator('[data-testid="modal-title"]')).toContainText( "Request pending", ); }); @@ -276,9 +274,7 @@ test.describe("Join link embed flows", () => { }); await action.click(); - await page - .locator('[data-testid="join-group-chat-dialog-confirm"]') - .click(); + await page.locator('[data-testid="modal-confirm-button"]').click(); await expect(action).toHaveAttribute("data-teststate", "requested", { timeout: 10000, @@ -300,15 +296,13 @@ test.describe("Join link embed flows", () => { }); await action.click(); - await page - .locator('[data-testid="join-group-chat-dialog-confirm"]') - .click(); + await page.locator('[data-testid="modal-confirm-button"]').click(); await expect(page.locator('[data-testid="toast"]')).toContainText( "Could not send join request", ); await expect( - page.locator('[data-testid="join-group-chat-dialog"]'), + page.locator('[data-testid="modal-confirm-button"]'), ).toBeVisible(); }); @@ -321,15 +315,13 @@ test.describe("Join link embed flows", () => { await page.locator('[data-testid="join-link-embed-action"]').click(); await expect( - page.locator('[data-testid="join-group-chat-dialog"]'), + page.locator('[data-testid="modal-confirm-button"]'), ).toBeVisible(); - await page - .locator('[data-testid="join-group-chat-dialog-cancel"]') - .click(); + await page.locator('[data-testid="modal-cancel-button"]').click(); await expect( - page.locator('[data-testid="join-group-chat-dialog"]'), + page.locator('[data-testid="modal-confirm-button"]'), ).toHaveCount(0); await expect( page.locator('[data-testid="join-link-embed-action"]'), @@ -342,7 +334,7 @@ test.describe("Join link embed flows", () => { await page.goto(postPath); await page.locator('[data-testid="join-link-embed-action"]').click(); - const dialog = page.locator('[data-testid="join-group-chat-dialog"]'); + const dialog = page.locator("dialog.action-modal"); await expect(dialog).toBeVisible(); // Click at the very top-left corner (outside the dialog content) diff --git a/tests/e2e/specs/views/settings/mutedWords.view.test.js b/tests/e2e/specs/views/settings/mutedWords.view.test.js index 5e93ebd8..f8f404a0 100644 --- a/tests/e2e/specs/views/settings/mutedWords.view.test.js +++ b/tests/e2e/specs/views/settings/mutedWords.view.test.js @@ -129,7 +129,7 @@ test.describe("Settings Muted Words view", () => { await view.locator('[data-testid="muted-word-delete"]').click(); // Confirm dialog should appear - const dialog = page.locator("dialog.modal-dialog"); + const dialog = page.locator("dialog.confirm-modal"); await expect(dialog).toBeVisible({ timeout: 5000 }); // The interpolated word name is the SUT — verify it flows into the dialog. await expect(dialog.locator('[data-testid="modal-message"]')).toContainText( diff --git a/tests/e2e/specs/views/settings/plugins.view.test.js b/tests/e2e/specs/views/settings/plugins.view.test.js index 316b73bf..b67c0e2f 100644 --- a/tests/e2e/specs/views/settings/plugins.view.test.js +++ b/tests/e2e/specs/views/settings/plugins.view.test.js @@ -111,7 +111,7 @@ test.describe("Settings plugins view", () => { await sampleItem.locator(".plugin-uninstall-button").click(); - const dialog = page.locator("dialog.modal-dialog"); + const dialog = page.locator("dialog.confirm-modal"); await expect(dialog).toBeVisible(); await expect(dialog.locator(".modal-dialog-title")).toContainText( "Uninstall plugin?", @@ -137,7 +137,7 @@ test.describe("Settings plugins view", () => { await expect(sampleItem).toBeVisible({ timeout: 10000 }); await sampleItem.locator(".plugin-uninstall-button").click(); - const dialog = page.locator("dialog.modal-dialog"); + const dialog = page.locator("dialog.confirm-modal"); await dialog.locator(".cancel-button").click(); await expect(sampleItem).toBeVisible(); diff --git a/tests/unit/specs/components/edit-profile-dialog.test.js b/tests/unit/specs/components/edit-profile-dialog.test.js index d533529a..d71cdd4c 100644 --- a/tests/unit/specs/components/edit-profile-dialog.test.js +++ b/tests/unit/specs/components/edit-profile-dialog.test.js @@ -450,7 +450,7 @@ t.describe("EditProfileDialog - close", (it) => { cancelButton.click(); await new Promise((resolve) => setTimeout(resolve, 0)); - const confirmDialog = document.body.querySelector(".modal-dialog"); + const confirmDialog = document.body.querySelector(".confirm-modal"); assert( confirmDialog !== null, "Discard confirmation should be shown when there are unsaved changes", @@ -485,7 +485,7 @@ t.describe("EditProfileDialog - close", (it) => { dialog.dispatchEvent(new Event("cancel", { cancelable: true })); await new Promise((resolve) => setTimeout(resolve, 0)); - const confirmDialog = document.body.querySelector(".modal-dialog"); + const confirmDialog = document.body.querySelector(".confirm-modal"); assertEquals( confirmDialog, null, diff --git a/tests/unit/specs/modals.test.js b/tests/unit/specs/modals.test.js index cf0bbf6d..7610c375 100644 --- a/tests/unit/specs/modals.test.js +++ b/tests/unit/specs/modals.test.js @@ -142,7 +142,7 @@ t.describe("confirm", (it) => { it("should create a dialog in the DOM", () => { clearDOM(); confirm("Are you sure?"); - const dialog = document.querySelector("dialog.modal-dialog"); + const dialog = document.querySelector("dialog.confirm-modal"); assert(dialog !== null); });