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``,
- 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`
+
+ ${title
+ ? html`
+ ${title}
+
`
+ : null}
+
+ ${message}
+
+
+
+
+
+
+ `,
+ 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);
});