[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Refactor group chat join confirmation modal

Grace Kind (Jun 28, 2026, 8:29 PM -0500) 36229dac 9c657cc1

+171 -217
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.69", 3 + "version": "0.17.70", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+34 -28
src/css/style.css
··· 4097 4097 height: 18px; 4098 4098 } 4099 4099 4100 - .join-group-chat-dialog .join-group-chat-dialog-content { 4101 - padding: 20px; 4102 - display: flex; 4103 - flex-direction: column; 4104 - gap: 16px; 4105 - } 4106 - 4107 - .join-group-chat-dialog-title { 4108 - font-size: 18px; 4109 - font-weight: 700; 4110 - margin: 0; 4111 - } 4112 - 4113 - .join-group-chat-dialog-body { 4114 - font-size: 16px; 4115 - color: var(--text-color); 4116 - margin: 0; 4117 - line-height: 1.4; 4118 - } 4119 - 4120 - .join-group-chat-dialog-actions { 4121 - display: flex; 4122 - justify-content: flex-end; 4123 - gap: 8px; 4124 - } 4125 - 4126 4100 .message-emoji-trigger { 4127 4101 background: transparent; 4128 4102 border: none; ··· 5615 5589 .bottom-sheet::backdrop { 5616 5590 background-color: var(--overlay-color-medium); 5617 5591 animation: fade-in 0.2s ease-out; 5592 + } 5593 + 5594 + .bottom-sheet.action-modal { 5595 + color: var(--text-color); 5596 + } 5597 + 5598 + .bottom-sheet.action-modal .modal-dialog-content { 5599 + padding: 32px 24px 24px; 5600 + } 5601 + 5602 + @media (min-width: 800px) { 5603 + .bottom-sheet.action-modal { 5604 + max-width: 410px; 5605 + width: 90%; 5606 + } 5607 + .bottom-sheet.action-modal .modal-dialog-content { 5608 + padding: 24px; 5609 + } 5618 5610 } 5619 5611 5620 5612 .post-composer-top-bar { ··· 9362 9354 opacity: 0.75; 9363 9355 } 9364 9356 9365 - .modal-dialog.confirm-modal { 9366 - max-width: 320px; 9357 + .bottom-sheet.confirm-modal { 9358 + color: var(--text-color); 9359 + } 9360 + 9361 + .bottom-sheet.confirm-modal .modal-dialog-content { 9362 + padding: 32px 24px 24px; 9363 + } 9364 + 9365 + @media (min-width: 800px) { 9366 + .bottom-sheet.confirm-modal { 9367 + max-width: 320px; 9368 + width: 90%; 9369 + } 9370 + .bottom-sheet.confirm-modal .modal-dialog-content { 9371 + padding: 24px; 9372 + } 9367 9373 } 9368 9374 9369 9375 .sidebar-action-items {
-139
src/js/components/join-group-chat-dialog.js
··· 1 - import { html, render } from "/js/lib/lit-html.js"; 2 - import { Component } from "/js/components/component.js"; 3 - import { ScrollLock } from "/js/scrollLock.js"; 4 - import { enableDragToDismiss } from "/js/utils.js"; 5 - import { Signal, ReactiveStore, effect } from "/js/signals.js"; 6 - 7 - class JoinGroupChatDialog extends Component { 8 - static get observedAttributes() { 9 - return ["name", "require-approval"]; 10 - } 11 - 12 - connectedCallback() { 13 - if (this.initialized) return; 14 - this.setAttribute("data-dialog-wrapper", ""); 15 - this.scrollLock = new ScrollLock(this); 16 - this.state = new ReactiveStore("join-group-chat-dialog"); 17 - this.state.$isOpen = new Signal.State(false); 18 - this.state.$isSubmitting = new Signal.State(false); 19 - this.innerHTML = ""; 20 - this._disposeEffect = effect(() => this.render()); 21 - this.initialized = true; 22 - } 23 - 24 - disconnectedCallback() { 25 - this._disposeEffect?.(); 26 - this._disposeEffect = null; 27 - } 28 - 29 - attributeChangedCallback() { 30 - if (this.initialized) this.render(); 31 - } 32 - 33 - render() { 34 - const isOpen = this.state.$isOpen.get(); 35 - const isSubmitting = this.state.$isSubmitting.get(); 36 - if (!isOpen) { 37 - render(html``, this); 38 - return; 39 - } 40 - const name = this.getAttribute("name") ?? ""; 41 - const requireApproval = this.hasAttribute("require-approval"); 42 - render( 43 - html`<dialog 44 - class="bottom-sheet join-group-chat-dialog" 45 - data-testid="join-group-chat-dialog" 46 - @click=${(event) => { 47 - if (event.target.tagName === "DIALOG") this.close(); 48 - }} 49 - @cancel=${(event) => { 50 - event.preventDefault(); 51 - this.close(); 52 - }} 53 - > 54 - <div class="join-group-chat-dialog-content"> 55 - <h2 56 - class="join-group-chat-dialog-title" 57 - data-testid="join-group-chat-dialog-title" 58 - > 59 - ${requireApproval ? "Request to join" : "Join group chat"} 60 - </h2> 61 - <p class="join-group-chat-dialog-body"> 62 - ${requireApproval 63 - ? html`Send a request to join <strong>${name}</strong>. The group 64 - owner will review your request before you can see messages.` 65 - : html`You're about to join <strong>${name}</strong>.`} 66 - </p> 67 - <div class="join-group-chat-dialog-actions"> 68 - <button 69 - class="rounded-button" 70 - data-testid="join-group-chat-dialog-cancel" 71 - ?disabled=${isSubmitting} 72 - @click=${() => this.close()} 73 - > 74 - Cancel 75 - </button> 76 - <button 77 - class="rounded-button rounded-button-primary" 78 - data-testid="join-group-chat-dialog-confirm" 79 - ?disabled=${isSubmitting} 80 - @click=${() => this._onConfirm()} 81 - > 82 - ${isSubmitting 83 - ? "Sending…" 84 - : requireApproval 85 - ? "Send request" 86 - : "Join"} 87 - </button> 88 - </div> 89 - </div> 90 - </dialog>`, 91 - this, 92 - ); 93 - } 94 - 95 - _onConfirm() { 96 - if (this.state.$isSubmitting.get()) return; 97 - this.state.$isSubmitting.set(true); 98 - this.dispatchEvent( 99 - new CustomEvent("confirm", { 100 - detail: { 101 - successCallback: () => { 102 - this.state.$isSubmitting.set(false); 103 - this.close(); 104 - }, 105 - errorCallback: () => { 106 - this.state.$isSubmitting.set(false); 107 - }, 108 - }, 109 - }), 110 - ); 111 - } 112 - 113 - open() { 114 - this.state.$isOpen.set(true); 115 - this.state.$isSubmitting.set(false); 116 - this.render(); 117 - this.scrollLock.lock(); 118 - const dialog = this.querySelector("dialog"); 119 - if (!dialog) return; 120 - dialog.showModal(); 121 - enableDragToDismiss(dialog, { 122 - onClose: () => this.close(), 123 - confirmDismiss: () => !this.state.$isSubmitting.get(), 124 - allowUpwardStretch: true, 125 - ignoreTouchTarget: (element) => element.closest("button") !== null, 126 - }); 127 - } 128 - 129 - close() { 130 - if (this.state.$isSubmitting.get()) return; 131 - this.scrollLock.unlock(); 132 - const dialog = this.querySelector("dialog"); 133 - if (dialog?.open) dialog.close(); 134 - this.state.$isOpen.set(false); 135 - this.dispatchEvent(new CustomEvent("dialog-closed")); 136 - } 137 - } 138 - 139 - JoinGroupChatDialog.register();
+16 -22
src/js/groupChatLinkService.js
··· 1 - import "/js/components/join-group-chat-dialog.js"; 1 + import { html } from "/js/lib/lit-html.js"; 2 + import { showActionModal, showInfoModal } from "/js/modals.js"; 2 3 import { showToast } from "/js/toasts.js"; 3 4 4 5 export class GroupChatLinkService { ··· 30 31 return; 31 32 } 32 33 if (actionType === "requested") { 33 - showToast("Request pending — the group owner will review it.", { 34 - style: "default", 34 + showInfoModal({ 35 + title: "Request pending", 36 + message: "The group owner will review your request.", 35 37 }); 36 38 } 37 39 } 38 40 39 41 _openJoinDialog(preview) { 40 - if (this.currentDialog) { 41 - console.warn("Join group chat dialog already open"); 42 - return; 43 - } 44 - const dialog = document.createElement("join-group-chat-dialog"); 45 - dialog.setAttribute("name", preview.name ?? ""); 46 - if (preview.requireApproval) dialog.setAttribute("require-approval", ""); 47 - dialog.addEventListener("confirm", (event) => 48 - this._submit({ preview, ...event.detail }), 49 - ); 50 - dialog.addEventListener("dialog-closed", () => { 51 - dialog.remove(); 52 - this.currentDialog = null; 42 + const name = preview.name ?? ""; 43 + showActionModal({ 44 + title: preview.requireApproval ? "Request to join" : "Join group chat", 45 + message: preview.requireApproval 46 + ? html`Send a request to join <strong>${name}</strong>. The group owner 47 + will review your request before you can see messages.` 48 + : html`You're about to join <strong>${name}</strong>.`, 49 + confirmButtonText: preview.requireApproval ? "Send request" : "Join", 50 + onConfirm: () => this._submit(preview), 53 51 }); 54 - this.currentDialog = dialog; 55 - document.body.appendChild(dialog); 56 - dialog.open(); 57 52 } 58 53 59 - async _submit({ preview, successCallback, errorCallback }) { 54 + async _submit(preview) { 60 55 try { 61 56 await this.dataLayer.mutations.requestJoinGroupChat(preview.code); 62 - successCallback(); 63 57 showToast( 64 58 preview.requireApproval 65 59 ? "Request sent — the group owner will review your request." ··· 68 62 ); 69 63 } catch (error) { 70 64 console.error(error); 71 - errorCallback(); 72 65 showToast("Could not send join request. Please try again.", { 73 66 style: "error", 74 67 }); 68 + throw error; 75 69 } 76 70 } 77 71 }
+102 -1
src/js/modals.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { getThreadgateAllowSettings } from "/js/dataHelpers.js"; 3 3 import { linkToProfile, linkToLogin } from "/js/navigation.js"; 4 + import { enableDragToDismiss } from "/js/utils.js"; 4 5 5 6 export function showSignInModal() { 6 7 const dialog = document.createElement("dialog"); ··· 106 107 ) { 107 108 return new Promise((resolve) => { 108 109 const dialog = document.createElement("dialog"); 109 - dialog.classList.add("modal-dialog", "confirm-modal"); 110 + dialog.classList.add("bottom-sheet", "confirm-modal"); 110 111 111 112 render( 112 113 html` ··· 165 166 166 167 document.body.appendChild(dialog); 167 168 dialog.showModal(); 169 + enableDragToDismiss(dialog, { 170 + onClose: () => dismiss(false), 171 + ignoreTouchTarget: (element) => element.closest("button") !== null, 172 + }); 168 173 169 174 // Allow tests to resolve externally 170 175 globalThis.__testConfirmation?.(resolve); 176 + }); 177 + } 178 + 179 + export async function showActionModal({ 180 + title = null, 181 + message, 182 + confirmButtonText = "Confirm", 183 + confirmButtonStyle = "primary", 184 + onConfirm, 185 + }) { 186 + return new Promise((resolve) => { 187 + const dialog = document.createElement("dialog"); 188 + dialog.classList.add("bottom-sheet", "action-modal"); 189 + 190 + let isPending = false; 191 + 192 + const renderContents = () => { 193 + render( 194 + html` 195 + <div class="modal-dialog-content"> 196 + ${title 197 + ? html`<h2 class="modal-dialog-title" data-testid="modal-title"> 198 + ${title} 199 + </h2>` 200 + : null} 201 + <p class="modal-dialog-message" data-testid="modal-message"> 202 + ${message} 203 + </p> 204 + <div class="modal-dialog-buttons"> 205 + <button 206 + class="modal-dialog-button cancel-button" 207 + data-testid="modal-cancel-button" 208 + ?disabled=${isPending} 209 + @click=${() => dismiss(false)} 210 + > 211 + Cancel 212 + </button> 213 + <button 214 + class="modal-dialog-button confirm-button ${confirmButtonStyle}-button" 215 + data-testid="modal-confirm-button" 216 + ?disabled=${isPending} 217 + @click=${runConfirm} 218 + > 219 + ${isPending 220 + ? html`<span 221 + class="loading-spinner" 222 + data-testid="loading-spinner" 223 + ></span>` 224 + : confirmButtonText} 225 + </button> 226 + </div> 227 + </div> 228 + `, 229 + dialog, 230 + ); 231 + }; 232 + 233 + const dismiss = (result) => { 234 + if (isPending) return; 235 + dialog.close(); 236 + dialog.remove(); 237 + resolve(result); 238 + }; 239 + 240 + const runConfirm = async () => { 241 + if (isPending) return; 242 + isPending = true; 243 + renderContents(); 244 + try { 245 + await onConfirm?.(); 246 + dialog.close(); 247 + dialog.remove(); 248 + resolve(true); 249 + } catch { 250 + isPending = false; 251 + renderContents(); 252 + } 253 + }; 254 + 255 + renderContents(); 256 + 257 + dialog.addEventListener("click", (event) => { 258 + if (event.target.tagName === "DIALOG") dismiss(false); 259 + }); 260 + dialog.addEventListener("cancel", (event) => { 261 + event.preventDefault(); 262 + dismiss(false); 263 + }); 264 + 265 + document.body.appendChild(dialog); 266 + dialog.showModal(); 267 + enableDragToDismiss(dialog, { 268 + onClose: () => dismiss(false), 269 + confirmDismiss: () => !isPending, 270 + ignoreTouchTarget: (element) => element.closest("button") !== null, 271 + }); 171 272 }); 172 273 } 173 274
+1 -1
tests/e2e/specs/concerns/postEmbeds.test.js
··· 643 643 .locator('[data-testid="join-link-embed-action"]') 644 644 .click({ timeout: 10000 }); 645 645 await expect( 646 - page.locator('[data-testid="join-group-chat-dialog"]'), 646 + page.locator('[data-testid="modal-confirm-button"]'), 647 647 ).toBeVisible(); 648 648 }); 649 649
+11 -19
tests/e2e/specs/flows/joinLinkEmbed.test.js
··· 221 221 await page.locator('[data-testid="join-link-embed-action"]').click(); 222 222 223 223 await expect( 224 - page.locator('[data-testid="join-group-chat-dialog"]'), 224 + page.locator('[data-testid="modal-confirm-button"]'), 225 225 ).toBeVisible(); 226 - await page 227 - .locator('[data-testid="join-group-chat-dialog-confirm"]') 228 - .click(); 226 + await page.locator('[data-testid="modal-confirm-button"]').click(); 229 227 230 228 await expect(page.locator('[data-testid="toast"]')).toContainText( 231 229 "Request sent", 232 230 ); 233 231 await expect( 234 - page.locator('[data-testid="join-group-chat-dialog"]'), 232 + page.locator('[data-testid="modal-confirm-button"]'), 235 233 ).toHaveCount(0); 236 234 }); 237 235 ··· 253 251 }); 254 252 await action.click(); 255 253 256 - await expect(page.locator('[data-testid="toast"]')).toContainText( 254 + await expect(page.locator('[data-testid="modal-title"]')).toContainText( 257 255 "Request pending", 258 256 ); 259 257 }); ··· 276 274 }); 277 275 await action.click(); 278 276 279 - await page 280 - .locator('[data-testid="join-group-chat-dialog-confirm"]') 281 - .click(); 277 + await page.locator('[data-testid="modal-confirm-button"]').click(); 282 278 283 279 await expect(action).toHaveAttribute("data-teststate", "requested", { 284 280 timeout: 10000, ··· 300 296 }); 301 297 await action.click(); 302 298 303 - await page 304 - .locator('[data-testid="join-group-chat-dialog-confirm"]') 305 - .click(); 299 + await page.locator('[data-testid="modal-confirm-button"]').click(); 306 300 307 301 await expect(page.locator('[data-testid="toast"]')).toContainText( 308 302 "Could not send join request", 309 303 ); 310 304 await expect( 311 - page.locator('[data-testid="join-group-chat-dialog"]'), 305 + page.locator('[data-testid="modal-confirm-button"]'), 312 306 ).toBeVisible(); 313 307 }); 314 308 ··· 321 315 322 316 await page.locator('[data-testid="join-link-embed-action"]').click(); 323 317 await expect( 324 - page.locator('[data-testid="join-group-chat-dialog"]'), 318 + page.locator('[data-testid="modal-confirm-button"]'), 325 319 ).toBeVisible(); 326 320 327 - await page 328 - .locator('[data-testid="join-group-chat-dialog-cancel"]') 329 - .click(); 321 + await page.locator('[data-testid="modal-cancel-button"]').click(); 330 322 331 323 await expect( 332 - page.locator('[data-testid="join-group-chat-dialog"]'), 324 + page.locator('[data-testid="modal-confirm-button"]'), 333 325 ).toHaveCount(0); 334 326 await expect( 335 327 page.locator('[data-testid="join-link-embed-action"]'), ··· 342 334 await page.goto(postPath); 343 335 344 336 await page.locator('[data-testid="join-link-embed-action"]').click(); 345 - const dialog = page.locator('[data-testid="join-group-chat-dialog"]'); 337 + const dialog = page.locator("dialog.action-modal"); 346 338 await expect(dialog).toBeVisible(); 347 339 348 340 // Click at the very top-left corner (outside the dialog content)
+1 -1
tests/e2e/specs/views/settings/mutedWords.view.test.js
··· 129 129 await view.locator('[data-testid="muted-word-delete"]').click(); 130 130 131 131 // Confirm dialog should appear 132 - const dialog = page.locator("dialog.modal-dialog"); 132 + const dialog = page.locator("dialog.confirm-modal"); 133 133 await expect(dialog).toBeVisible({ timeout: 5000 }); 134 134 // The interpolated word name is the SUT — verify it flows into the dialog. 135 135 await expect(dialog.locator('[data-testid="modal-message"]')).toContainText(
+2 -2
tests/e2e/specs/views/settings/plugins.view.test.js
··· 111 111 112 112 await sampleItem.locator(".plugin-uninstall-button").click(); 113 113 114 - const dialog = page.locator("dialog.modal-dialog"); 114 + const dialog = page.locator("dialog.confirm-modal"); 115 115 await expect(dialog).toBeVisible(); 116 116 await expect(dialog.locator(".modal-dialog-title")).toContainText( 117 117 "Uninstall plugin?", ··· 137 137 await expect(sampleItem).toBeVisible({ timeout: 10000 }); 138 138 await sampleItem.locator(".plugin-uninstall-button").click(); 139 139 140 - const dialog = page.locator("dialog.modal-dialog"); 140 + const dialog = page.locator("dialog.confirm-modal"); 141 141 await dialog.locator(".cancel-button").click(); 142 142 143 143 await expect(sampleItem).toBeVisible();
+2 -2
tests/unit/specs/components/edit-profile-dialog.test.js
··· 450 450 cancelButton.click(); 451 451 await new Promise((resolve) => setTimeout(resolve, 0)); 452 452 453 - const confirmDialog = document.body.querySelector(".modal-dialog"); 453 + const confirmDialog = document.body.querySelector(".confirm-modal"); 454 454 assert( 455 455 confirmDialog !== null, 456 456 "Discard confirmation should be shown when there are unsaved changes", ··· 485 485 dialog.dispatchEvent(new Event("cancel", { cancelable: true })); 486 486 await new Promise((resolve) => setTimeout(resolve, 0)); 487 487 488 - const confirmDialog = document.body.querySelector(".modal-dialog"); 488 + const confirmDialog = document.body.querySelector(".confirm-modal"); 489 489 assertEquals( 490 490 confirmDialog, 491 491 null,
+1 -1
tests/unit/specs/modals.test.js
··· 142 142 it("should create a dialog in the DOM", () => { 143 143 clearDOM(); 144 144 confirm("Are you sure?"); 145 - const dialog = document.querySelector("dialog.modal-dialog"); 145 + const dialog = document.querySelector("dialog.confirm-modal"); 146 146 assert(dialog !== null); 147 147 }); 148 148