[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 modals

Grace Kind (Jun 28, 2026, 9:57 PM -0500) dea9d8ca 36229dac

+1284 -1330
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.70", 3 + "version": "0.17.71", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+9 -30
src/css/style.css
··· 5591 5591 animation: fade-in 0.2s ease-out; 5592 5592 } 5593 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 - } 5610 - } 5611 - 5612 5594 .post-composer-top-bar { 5613 5595 display: flex; 5614 5596 justify-content: space-between; ··· 6237 6219 color: var(--text-color); 6238 6220 } 6239 6221 6240 - .info-modal .modal-dialog-message { 6222 + .alert-modal .modal-dialog-message { 6241 6223 margin-bottom: 12px; 6242 6224 } 6243 6225 ··· 6305 6287 6306 6288 .modal-dialog-button.full-width { 6307 6289 width: 100%; 6308 - } 6309 - 6310 - .modal-dialog.compact { 6311 - max-width: 320px; 6312 - width: auto; 6313 - min-width: 280px; 6314 6290 } 6315 6291 6316 6292 .modal-dialog-buttons { ··· 9354 9330 opacity: 0.75; 9355 9331 } 9356 9332 9357 - .bottom-sheet.confirm-modal { 9333 + .bottom-sheet.text-modal { 9358 9334 color: var(--text-color); 9359 9335 } 9360 9336 9361 - .bottom-sheet.confirm-modal .modal-dialog-content { 9337 + .bottom-sheet.text-modal .modal-dialog-content { 9362 9338 padding: 32px 24px 24px; 9363 9339 } 9364 9340 9365 9341 @media (min-width: 800px) { 9366 - .bottom-sheet.confirm-modal { 9342 + .bottom-sheet.text-modal { 9343 + width: 90%; 9344 + max-width: 410px; 9345 + } 9346 + .bottom-sheet.text-modal.compact { 9367 9347 max-width: 320px; 9368 - width: 90%; 9369 9348 } 9370 - .bottom-sheet.confirm-modal .modal-dialog-content { 9349 + .bottom-sheet.text-modal .modal-dialog-content { 9371 9350 padding: 24px; 9372 9351 } 9373 9352 }
+2 -2
src/js/components/edit-profile-dialog.js
··· 14 14 import "/js/components/context-menu-item.js"; 15 15 import "/js/components/context-menu-item-group.js"; 16 16 import { cameraIconTemplate } from "/js/templates/icons/cameraIcon.template.js"; 17 - import { confirm } from "/js/modals.js"; 17 + import { confirmModal } from "/js/modals/confirm.modal.js"; 18 18 19 19 const MAX_DISPLAY_NAME_LENGTH = 64; 20 20 const MAX_DESCRIPTION_LENGTH = 256; ··· 507 507 508 508 async confirmClose() { 509 509 if (!this._isDirty || !!this._croppingTarget || this._saving) return true; 510 - return confirm("Are you sure you want to discard your changes?", { 510 + return confirmModal("Are you sure you want to discard your changes?", { 511 511 title: "Discard changes?", 512 512 confirmButtonStyle: "danger", 513 513 confirmButtonText: "Discard",
+2 -2
src/js/components/post-composer.js
··· 11 11 sanitizeUri, 12 12 } from "/js/utils.js"; 13 13 import { externalLinkTemplate } from "/js/templates/externalLink.template.js"; 14 - import { confirm } from "/js/modals.js"; 14 + import { confirmModal } from "/js/modals/confirm.modal.js"; 15 15 import { ScrollLock } from "/js/scrollLock.js"; 16 16 import { imageIconTemplate } from "/js/templates/icons/imageIcon.template.js"; 17 17 import { emojiIconTemplate } from "/js/templates/icons/emojiIcon.template.js"; ··· 881 881 ) { 882 882 return true; 883 883 } 884 - return confirm("Are you sure you'd like to discard this draft?", { 884 + return confirmModal("Are you sure you'd like to discard this draft?", { 885 885 title: "Discard draft?", 886 886 confirmButtonStyle: "danger", 887 887 confirmButtonText: "Discard",
+81 -17
src/js/groupChatLinkService.js
··· 1 1 import { html } from "/js/lib/lit-html.js"; 2 - import { showActionModal, showInfoModal } from "/js/modals.js"; 2 + import { Modal } from "/js/modals/modal.js"; 3 + import { alertModal } from "/js/modals/alert.modal.js"; 3 4 import { showToast } from "/js/toasts.js"; 4 5 6 + class JoinChatModal extends Modal { 7 + get className() { 8 + return "bottom-sheet text-modal join-chat-modal"; 9 + } 10 + 11 + get attributes() { 12 + return { "data-testid": "join-chat-modal" }; 13 + } 14 + 15 + constructor(options) { 16 + super(options); 17 + this.isPending = false; 18 + } 19 + 20 + canDismiss() { 21 + return !this.isPending; 22 + } 23 + 24 + render({ dismiss, update, props: { preview, onSubmit } }) { 25 + const name = preview.name ?? ""; 26 + const runConfirm = async () => { 27 + if (this.isPending) return; 28 + this.isPending = true; 29 + update(); 30 + try { 31 + await onSubmit(); 32 + this.isPending = false; 33 + dismiss(true); 34 + } catch { 35 + this.isPending = false; 36 + update(); 37 + } 38 + }; 39 + return html` 40 + <div class="modal-dialog-content"> 41 + <h2 class="modal-dialog-title" data-testid="modal-title"> 42 + ${preview.requireApproval ? "Request to join" : "Join group chat"} 43 + </h2> 44 + <p class="modal-dialog-message" data-testid="modal-message"> 45 + ${preview.requireApproval 46 + ? html`Send a request to join <strong>${name}</strong>. The group 47 + owner will review your request before you can see messages.` 48 + : html`You're about to join <strong>${name}</strong>.`} 49 + </p> 50 + <div class="modal-dialog-buttons"> 51 + <button 52 + class="modal-dialog-button cancel-button" 53 + data-testid="modal-cancel-button" 54 + ?disabled=${this.isPending} 55 + @click=${() => dismiss(false)} 56 + > 57 + Cancel 58 + </button> 59 + <button 60 + class="modal-dialog-button confirm-button primary-button" 61 + data-testid="modal-confirm-button" 62 + ?disabled=${this.isPending} 63 + @click=${runConfirm} 64 + > 65 + ${this.isPending 66 + ? html`<span 67 + class="loading-spinner" 68 + data-testid="loading-spinner" 69 + ></span>` 70 + : preview.requireApproval 71 + ? "Send request" 72 + : "Join"} 73 + </button> 74 + </div> 75 + </div> 76 + `; 77 + } 78 + } 79 + 5 80 export class GroupChatLinkService { 6 81 constructor(dataLayer) { 7 82 this.dataLayer = dataLayer; ··· 27 102 return; 28 103 } 29 104 if (actionType === "join" || actionType === "request") { 30 - this._openJoinDialog(preview); 105 + JoinChatModal.open({ 106 + preview, 107 + onSubmit: () => this._submit(preview), 108 + }); 31 109 return; 32 110 } 33 111 if (actionType === "requested") { 34 - showInfoModal({ 112 + alertModal("The group owner will review your request.", { 35 113 title: "Request pending", 36 - message: "The group owner will review your request.", 37 114 }); 38 115 } 39 - } 40 - 41 - _openJoinDialog(preview) { 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), 51 - }); 52 116 } 53 117 54 118 async _submit(preview) {
+3 -3
src/js/listInteractionHandler.js
··· 1 1 import { hapticsImpactMedium } from "/js/haptics.js"; 2 2 import { showToast } from "/js/toasts.js"; 3 - import { confirm } from "/js/modals.js"; 3 + import { confirmModal } from "/js/modals/confirm.modal.js"; 4 4 5 5 export class ListInteractionHandler { 6 6 constructor(dataLayer) { ··· 29 29 } 30 30 31 31 async handleMuteList(list) { 32 - const confirmed = await confirm( 32 + const confirmed = await confirmModal( 33 33 "The users on this list will be muted for you. Their posts won't appear in your feeds. Muting is private.", 34 34 { 35 35 title: "Mute these accounts?", ··· 58 58 } 59 59 60 60 async handleBlockList(list) { 61 - const confirmed = await confirm( 61 + const confirmed = await confirmModal( 62 62 "The users on this list will be blocked. They won't be able to interact with you, and you won't see their content. Blocking is public.", 63 63 { 64 64 title: "Block these accounts?",
-590
src/js/modals.js
··· 1 - import { html, render } from "/js/lib/lit-html.js"; 2 - import { getThreadgateAllowSettings } from "/js/dataHelpers.js"; 3 - import { linkToProfile, linkToLogin } from "/js/navigation.js"; 4 - import { enableDragToDismiss } from "/js/utils.js"; 5 - 6 - export function showSignInModal() { 7 - const dialog = document.createElement("dialog"); 8 - dialog.classList.add("modal-dialog", "compact"); 9 - 10 - render( 11 - html` 12 - <div class="modal-dialog-content"> 13 - <h2 14 - class="modal-dialog-title modal-dialog-title-large" 15 - data-testid="modal-title" 16 - > 17 - Sign in 18 - </h2> 19 - <p class="modal-dialog-message" data-testid="modal-message"> 20 - Sign in to join the conversation! 21 - </p> 22 - <a 23 - href=${linkToLogin()} 24 - class="modal-dialog-button primary-button full-width" 25 - data-testid="modal-primary-button" 26 - @click=${() => { 27 - dialog.close(); 28 - dialog.remove(); 29 - }} 30 - > 31 - Sign in 32 - </a> 33 - </div> 34 - `, 35 - dialog, 36 - ); 37 - 38 - // Dismiss on backdrop click 39 - dialog.addEventListener("click", (e) => { 40 - if (e.target.tagName === "DIALOG") { 41 - dialog.close(); 42 - dialog.remove(); 43 - } 44 - }); 45 - 46 - document.body.appendChild(dialog); 47 - dialog.showModal(); 48 - } 49 - 50 - export function showInfoModal({ title, message, confirmButtonText = "OK" }) { 51 - const dialog = document.createElement("dialog"); 52 - dialog.classList.add("modal-dialog", "info-modal"); 53 - 54 - render( 55 - html` 56 - <div class="modal-dialog-content"> 57 - <h2 class="modal-dialog-title" data-testid="modal-title">${title}</h2> 58 - <p class="modal-dialog-message" data-testid="modal-message"> 59 - ${message} 60 - </p> 61 - <div class="modal-dialog-buttons"> 62 - <button 63 - class="modal-dialog-button primary-button" 64 - data-testid="modal-primary-button" 65 - > 66 - ${confirmButtonText} 67 - </button> 68 - </div> 69 - </div> 70 - `, 71 - dialog, 72 - ); 73 - 74 - const okButton = dialog.querySelector(".primary-button"); 75 - 76 - const dismiss = () => { 77 - dialog.close(); 78 - dialog.remove(); 79 - }; 80 - 81 - okButton.addEventListener("click", dismiss); 82 - 83 - // Dismiss on backdrop click 84 - dialog.addEventListener("click", (e) => { 85 - if (e.target.tagName === "DIALOG") { 86 - dismiss(); 87 - } 88 - }); 89 - 90 - // Dismiss on Escape key 91 - dialog.addEventListener("cancel", (e) => { 92 - e.preventDefault(); 93 - dismiss(); 94 - }); 95 - 96 - document.body.appendChild(dialog); 97 - dialog.showModal(); 98 - } 99 - 100 - export async function confirm( 101 - message, 102 - { 103 - title = null, 104 - confirmButtonStyle = "primary", 105 - confirmButtonText = "Confirm", 106 - } = {}, 107 - ) { 108 - return new Promise((resolve) => { 109 - const dialog = document.createElement("dialog"); 110 - dialog.classList.add("bottom-sheet", "confirm-modal"); 111 - 112 - render( 113 - html` 114 - <div class="modal-dialog-content"> 115 - ${title 116 - ? html`<h2 class="modal-dialog-title" data-testid="modal-title"> 117 - ${title} 118 - </h2>` 119 - : null} 120 - <p class="modal-dialog-message" data-testid="modal-message"> 121 - ${message} 122 - </p> 123 - <div class="modal-dialog-buttons"> 124 - <button 125 - class="modal-dialog-button cancel-button" 126 - data-testid="modal-cancel-button" 127 - > 128 - Cancel 129 - </button> 130 - <button 131 - class="modal-dialog-button confirm-button ${confirmButtonStyle}-button" 132 - data-testid="modal-confirm-button" 133 - > 134 - ${confirmButtonText} 135 - </button> 136 - </div> 137 - </div> 138 - `, 139 - dialog, 140 - ); 141 - 142 - const cancelButton = dialog.querySelector(".cancel-button"); 143 - const confirmButton = dialog.querySelector(".confirm-button"); 144 - 145 - const dismiss = (result) => { 146 - dialog.close(); 147 - dialog.remove(); 148 - resolve(result); 149 - }; 150 - 151 - cancelButton.addEventListener("click", () => dismiss(false)); 152 - confirmButton.addEventListener("click", () => dismiss(true)); 153 - 154 - // Dismiss on backdrop click 155 - dialog.addEventListener("click", (e) => { 156 - if (e.target.tagName === "DIALOG") { 157 - dismiss(false); 158 - } 159 - }); 160 - 161 - // Dismiss on Escape key 162 - dialog.addEventListener("cancel", (e) => { 163 - e.preventDefault(); 164 - dismiss(false); 165 - }); 166 - 167 - document.body.appendChild(dialog); 168 - dialog.showModal(); 169 - enableDragToDismiss(dialog, { 170 - onClose: () => dismiss(false), 171 - ignoreTouchTarget: (element) => element.closest("button") !== null, 172 - }); 173 - 174 - // Allow tests to resolve externally 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 - }); 272 - }); 273 - } 274 - 275 - function ruleTemplate({ rule, author }) { 276 - if (rule.type === "mention") { 277 - return html`mentioned users`; 278 - } 279 - if (rule.type === "followers") { 280 - return html`users following 281 - <a href=${linkToProfile(author)}>@${author.handle}</a>`; 282 - } 283 - if (rule.type === "following") { 284 - return html`users followed by 285 - <a href=${linkToProfile(author)}>@${author.handle}</a>`; 286 - } 287 - if (rule.type === "list") { 288 - if (rule.list) { 289 - return html`${rule.list.name} members`; 290 - } 291 - return html`list members`; 292 - } 293 - return html`unknown`; 294 - } 295 - 296 - function threadgateRuleTemplate({ post }) { 297 - const settings = getThreadgateAllowSettings(post); 298 - if (!Array.isArray(settings)) { 299 - if (settings.type === "everybody") { 300 - return html`Everybody can reply to this post.`; 301 - } 302 - if (settings.type === "nobody") { 303 - return html`Replies to this post are disabled.`; 304 - } 305 - } 306 - if (Array.isArray(settings)) { 307 - if (settings.some((rule) => rule.type === "unknown")) { 308 - return html`This post has an unknown type of threadgate on it. Your app 309 - may be out of date.`; 310 - } 311 - const author = post.author; 312 - const parts = []; 313 - settings.forEach((rule, i) => { 314 - if (i > 0) { 315 - if (i === settings.length - 1) { 316 - parts.push(html`, and `); 317 - } else { 318 - parts.push(html`, `); 319 - } 320 - } 321 - parts.push(ruleTemplate({ rule, author })); 322 - }); 323 - return html`Only ${parts} can reply.`; 324 - } 325 - return null; 326 - } 327 - 328 - export async function showExternalLinkWarningModal({ href }) { 329 - return new Promise((resolve) => { 330 - const dialog = document.createElement("dialog"); 331 - dialog.classList.add("modal-dialog", "external-link-warning-modal"); 332 - 333 - const url = new URL(href); 334 - 335 - render( 336 - html` 337 - <div class="modal-dialog-content"> 338 - <h2 339 - class="modal-dialog-title" 340 - data-testid="external-link-warning-title" 341 - > 342 - Leave this app? 343 - </h2> 344 - <p 345 - class="modal-dialog-message" 346 - data-testid="external-link-warning-message" 347 - > 348 - This link will take you to: 349 - </p> 350 - <a 351 - class="external-link-warning-href" 352 - href=${href} 353 - target="_blank" 354 - rel="noopener noreferrer" 355 - data-testid="external-link-warning-href" 356 - @click=${(event) => { 357 - event.stopPropagation(); 358 - dismiss(true); 359 - }} 360 - > 361 - <span class="external-link-warning-host">${url.host}</span 362 - ><span class="external-link-warning-path" 363 - >${url.pathname}${url.search}${url.hash}</span 364 - > 365 - </a> 366 - <div class="modal-dialog-buttons"> 367 - <button 368 - class="modal-dialog-button cancel-button" 369 - data-testid="external-link-warning-cancel-button" 370 - > 371 - Cancel 372 - </button> 373 - <button 374 - class="modal-dialog-button confirm-button primary-button" 375 - data-testid="external-link-warning-visit-button" 376 - > 377 - Visit site 378 - </button> 379 - </div> 380 - </div> 381 - `, 382 - dialog, 383 - ); 384 - 385 - const cancelButton = dialog.querySelector(".cancel-button"); 386 - const visitButton = dialog.querySelector(".confirm-button"); 387 - 388 - const dismiss = (result) => { 389 - dialog.close(); 390 - dialog.remove(); 391 - resolve(result); 392 - }; 393 - 394 - cancelButton.addEventListener("click", () => dismiss(false)); 395 - visitButton.addEventListener("click", () => { 396 - window.open(href, "_blank", "noopener,noreferrer"); 397 - dismiss(true); 398 - }); 399 - 400 - dialog.addEventListener("click", (event) => { 401 - if (event.target.tagName === "DIALOG") dismiss(false); 402 - }); 403 - 404 - dialog.addEventListener("cancel", (event) => { 405 - event.preventDefault(); 406 - dismiss(false); 407 - }); 408 - 409 - document.body.appendChild(dialog); 410 - dialog.showModal(); 411 - }); 412 - } 413 - 414 - export function showWhoCanReplyModal({ post }) { 415 - const dialog = document.createElement("dialog"); 416 - dialog.classList.add("modal-dialog", "info-modal"); 417 - dialog.dataset.testid = "who-can-reply-modal"; 418 - 419 - const dismiss = () => { 420 - dialog.close(); 421 - dialog.remove(); 422 - }; 423 - 424 - const embeddingDisabled = !!post?.viewer?.embeddingDisabled; 425 - 426 - render( 427 - html` 428 - <div class="modal-dialog-content"> 429 - <h2 class="modal-dialog-title" data-testid="modal-title"> 430 - Who can interact with this post? 431 - </h2> 432 - <div class="modal-dialog-message who-can-reply-body"> 433 - <span>${threadgateRuleTemplate({ post })}</span> 434 - ${embeddingDisabled 435 - ? html`<span>No one but the author can quote this post.</span>` 436 - : ""} 437 - </div> 438 - <div class="modal-dialog-buttons"> 439 - <button 440 - class="modal-dialog-button primary-button" 441 - data-testid="modal-primary-button" 442 - @click=${dismiss} 443 - > 444 - OK 445 - </button> 446 - </div> 447 - </div> 448 - `, 449 - dialog, 450 - ); 451 - 452 - dialog.addEventListener("click", (e) => { 453 - if (e.target.tagName === "DIALOG") { 454 - dismiss(); 455 - } 456 - }); 457 - dialog.addEventListener("cancel", (e) => { 458 - e.preventDefault(); 459 - dismiss(); 460 - }); 461 - 462 - document.body.appendChild(dialog); 463 - dialog.showModal(); 464 - } 465 - 466 - const pluginModals = new Map(); 467 - 468 - export function showPluginModal({ 469 - pluginRenderer, 470 - pluginId, 471 - modalId, 472 - title, 473 - content, 474 - onDismiss = () => {}, 475 - }) { 476 - let modal = pluginModals.get(`${pluginId}:${modalId}`); 477 - if (modal?.isOpen) return; 478 - 479 - if (!modal) { 480 - const dialog = document.createElement("dialog"); 481 - dialog.classList.add("modal-dialog", "plugin-modal"); 482 - dialog.dataset.pluginId = pluginId; 483 - 484 - const contentEl = document.createElement("div"); 485 - contentEl.classList.add("modal-dialog-content"); 486 - dialog.appendChild(contentEl); 487 - 488 - modal = { dialog, contentEl, isOpen: false }; 489 - 490 - function dismiss() { 491 - if (!modal.isOpen) return; 492 - modal.isOpen = false; 493 - dialog.close(); 494 - onDismiss(); 495 - } 496 - 497 - dialog.addEventListener("click", (event) => { 498 - if (event.target.tagName === "DIALOG") dismiss(); 499 - }); 500 - dialog.addEventListener("cancel", (event) => { 501 - event.preventDefault(); 502 - dismiss(); 503 - }); 504 - 505 - pluginModals.set(`${pluginId}:${modalId}`, modal); 506 - document.body.appendChild(dialog); 507 - } 508 - 509 - modal.contentEl.replaceChildren(); 510 - if (!pluginRenderer.isEmptyNode(title)) { 511 - const titleEl = pluginRenderer.createRoot().render(title); 512 - titleEl.classList.add("modal-dialog-title"); 513 - modal.contentEl.appendChild(titleEl); 514 - } 515 - if (content?.children?.length) { 516 - for (const childNode of content.children) { 517 - modal.contentEl.appendChild( 518 - pluginRenderer.createRoot().render(childNode), 519 - ); 520 - } 521 - } else if (!pluginRenderer.isEmptyNode(content)) { 522 - modal.contentEl.appendChild(pluginRenderer.createRoot().render(content)); 523 - } 524 - modal.isOpen = true; 525 - modal.dialog.showModal(); 526 - } 527 - 528 - export function hidePluginModal({ pluginId, modalId }) { 529 - const modal = pluginModals.get(`${pluginId}:${modalId}`); 530 - if (modal && modal.isOpen) { 531 - modal.isOpen = false; 532 - modal.dialog.close(); 533 - } 534 - } 535 - 536 - export async function showPluginInstallPermissionsModal({ 537 - pluginName, 538 - permissions, 539 - }) { 540 - const name = pluginName ?? "This plugin"; 541 - return confirm( 542 - html`<span data-testid="permission-prompt"> 543 - <span>${name} wants permission to:</span> 544 - ${permissionsListTemplate({ permissions })} 545 - </span>`, 546 - { 547 - title: "Grant permissions?", 548 - confirmButtonText: "Allow and install", 549 - }, 550 - ); 551 - } 552 - 553 - export async function showPluginUpdatePermissionsModal({ 554 - pluginName, 555 - pluginVersion, 556 - permissionsDiff, 557 - }) { 558 - const name = pluginName ?? "This plugin"; 559 - const heading = pluginVersion 560 - ? `${name} v${pluginVersion} requests new permissions:` 561 - : `${name} requests new permissions:`; 562 - return confirm( 563 - html`<span data-testid="permission-update-prompt"> 564 - <span>${heading}</span> 565 - ${permissionsListTemplate({ permissions: permissionsDiff })} 566 - </span>`, 567 - { 568 - title: "Grant new permissions?", 569 - confirmButtonText: "Allow and update", 570 - }, 571 - ); 572 - } 573 - 574 - function permissionsListTemplate({ permissions }) { 575 - const sections = []; 576 - const fetchPatterns = permissions.fetch ?? []; 577 - if (fetchPatterns.length > 0) { 578 - sections.push(html` 579 - <div class="permission-prompt-section"> 580 - <div>Send network requests to:</div> 581 - <ul class="permission-prompt-list"> 582 - ${fetchPatterns.map( 583 - (pattern) => html`<li><code>${pattern}</code></li>`, 584 - )} 585 - </ul> 586 - </div> 587 - `); 588 - } 589 - return sections; 590 - }
+40
src/js/modals/alert.modal.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + import { Modal } from "/js/modals/modal.js"; 3 + 4 + class AlertModal extends Modal { 5 + get className() { 6 + return "bottom-sheet text-modal alert-modal"; 7 + } 8 + 9 + get attributes() { 10 + return { "data-testid": "alert-modal" }; 11 + } 12 + 13 + render({ dismiss, props: { message, title, confirmButtonText = "OK" } }) { 14 + return html` 15 + <div class="modal-dialog-content"> 16 + ${title 17 + ? html`<h2 class="modal-dialog-title" data-testid="modal-title"> 18 + ${title} 19 + </h2>` 20 + : null} 21 + <p class="modal-dialog-message" data-testid="modal-message"> 22 + ${message} 23 + </p> 24 + <div class="modal-dialog-buttons"> 25 + <button 26 + class="modal-dialog-button primary-button" 27 + data-testid="modal-primary-button" 28 + @click=${() => dismiss()} 29 + > 30 + ${confirmButtonText} 31 + </button> 32 + </div> 33 + </div> 34 + `; 35 + } 36 + } 37 + 38 + export function alertModal(message, options = {}) { 39 + AlertModal.open({ message, ...options }); 40 + }
+66
src/js/modals/confirm.modal.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + import { Modal } from "/js/modals/modal.js"; 3 + 4 + class ConfirmModal extends Modal { 5 + get className() { 6 + return "bottom-sheet text-modal confirm-modal compact"; 7 + } 8 + 9 + get attributes() { 10 + return { "data-testid": "confirm-modal" }; 11 + } 12 + 13 + render({ 14 + dismiss, 15 + props: { 16 + message, 17 + title, 18 + confirmButtonStyle = "primary", 19 + confirmButtonText = "Confirm", 20 + }, 21 + }) { 22 + return html` 23 + <div class="modal-dialog-content"> 24 + ${title 25 + ? html`<h2 class="modal-dialog-title" data-testid="modal-title"> 26 + ${title} 27 + </h2>` 28 + : null} 29 + <p class="modal-dialog-message" data-testid="modal-message"> 30 + ${message} 31 + </p> 32 + <div class="modal-dialog-buttons"> 33 + <button 34 + class="modal-dialog-button cancel-button" 35 + data-testid="modal-cancel-button" 36 + @click=${() => dismiss(false)} 37 + > 38 + Cancel 39 + </button> 40 + <button 41 + class="modal-dialog-button confirm-button ${confirmButtonStyle}-button" 42 + data-testid="modal-confirm-button" 43 + @click=${() => dismiss(true)} 44 + > 45 + ${confirmButtonText} 46 + </button> 47 + </div> 48 + </div> 49 + `; 50 + } 51 + } 52 + 53 + export async function confirmModal(message, options = {}) { 54 + return new Promise((resolveOuter) => { 55 + let resolved = false; 56 + const resolveOnce = (value) => { 57 + if (resolved) return; 58 + resolved = true; 59 + resolveOuter(value); 60 + }; 61 + globalThis.__testConfirmation?.(resolveOnce); 62 + ConfirmModal.open({ message, ...options }).then((value) => 63 + resolveOnce(value ?? false), 64 + ); 65 + }); 66 + }
+63
src/js/modals/externalLinkWarning.modal.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + import { Modal } from "/js/modals/modal.js"; 3 + 4 + export class ExternalLinkWarningModal extends Modal { 5 + get className() { 6 + return "bottom-sheet text-modal external-link-warning-modal"; 7 + } 8 + 9 + render({ dismiss, props: { href } }) { 10 + const url = new URL(href); 11 + return html` 12 + <div class="modal-dialog-content"> 13 + <h2 14 + class="modal-dialog-title" 15 + data-testid="external-link-warning-title" 16 + > 17 + Leave this app? 18 + </h2> 19 + <p 20 + class="modal-dialog-message" 21 + data-testid="external-link-warning-message" 22 + > 23 + This link will take you to: 24 + </p> 25 + <a 26 + class="external-link-warning-href" 27 + href=${href} 28 + target="_blank" 29 + rel="noopener noreferrer" 30 + data-testid="external-link-warning-href" 31 + @click=${(event) => { 32 + event.stopPropagation(); 33 + dismiss(true); 34 + }} 35 + > 36 + <span class="external-link-warning-host">${url.host}</span 37 + ><span class="external-link-warning-path" 38 + >${url.pathname}${url.search}${url.hash}</span 39 + > 40 + </a> 41 + <div class="modal-dialog-buttons"> 42 + <button 43 + class="modal-dialog-button cancel-button" 44 + data-testid="external-link-warning-cancel-button" 45 + @click=${() => dismiss(false)} 46 + > 47 + Cancel 48 + </button> 49 + <button 50 + class="modal-dialog-button confirm-button primary-button" 51 + data-testid="external-link-warning-visit-button" 52 + @click=${() => { 53 + window.open(href, "_blank", "noopener,noreferrer"); 54 + dismiss(true); 55 + }} 56 + > 57 + Visit site 58 + </button> 59 + </div> 60 + </div> 61 + `; 62 + } 63 + }
+83
src/js/modals/modal.js
··· 1 + import { render } from "/js/lib/lit-html.js"; 2 + import { enableDragToDismiss } from "/js/utils.js"; 3 + 4 + export class Modal { 5 + static async open(...args) { 6 + const instance = new this(...args); 7 + return instance._mount(); 8 + } 9 + 10 + constructor(options = {}) { 11 + this.options = options; 12 + } 13 + 14 + get className() { 15 + return "bottom-sheet text-modal"; 16 + } 17 + 18 + get attributes() { 19 + return {}; 20 + } 21 + 22 + get dragToDismiss() { 23 + return true; 24 + } 25 + 26 + canDismiss() { 27 + return true; 28 + } 29 + 30 + ignoreTouchTarget(element) { 31 + return element.closest("button") !== null; 32 + } 33 + 34 + render() { 35 + throw new Error(`${this.constructor.name} must implement render()`); 36 + } 37 + 38 + _mount() { 39 + return new Promise((resolve) => { 40 + const dialog = document.createElement("dialog"); 41 + dialog.className = this.className; 42 + for (const [key, value] of Object.entries(this.attributes)) { 43 + dialog.setAttribute(key, value); 44 + } 45 + 46 + let resolved = false; 47 + const dismiss = (value) => { 48 + if (resolved) return; 49 + resolved = true; 50 + dialog.close(); 51 + dialog.remove(); 52 + resolve(value); 53 + }; 54 + const dismissIfAllowed = () => { 55 + if (this.canDismiss()) dismiss(); 56 + }; 57 + const update = () => { 58 + render(this.render({ dismiss, update, props: this.options }), dialog); 59 + }; 60 + 61 + update(); 62 + 63 + dialog.addEventListener("click", (event) => { 64 + if (event.target.tagName === "DIALOG") dismissIfAllowed(); 65 + }); 66 + dialog.addEventListener("cancel", (event) => { 67 + event.preventDefault(); 68 + dismissIfAllowed(); 69 + }); 70 + 71 + document.body.appendChild(dialog); 72 + dialog.showModal(); 73 + 74 + if (this.dragToDismiss) { 75 + enableDragToDismiss(dialog, { 76 + onClose: () => dismiss(), 77 + confirmDismiss: () => this.canDismiss(), 78 + ignoreTouchTarget: (element) => this.ignoreTouchTarget(element), 79 + }); 80 + } 81 + }); 82 + } 83 + }
+37
src/js/modals/signIn.modal.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + import { linkToLogin } from "/js/navigation.js"; 3 + import { Modal } from "/js/modals/modal.js"; 4 + 5 + export class SignInModal extends Modal { 6 + get className() { 7 + return "bottom-sheet text-modal sign-in-modal compact"; 8 + } 9 + 10 + get attributes() { 11 + return { "data-testid": "sign-in-modal" }; 12 + } 13 + 14 + render({ dismiss }) { 15 + return html` 16 + <div class="modal-dialog-content"> 17 + <h2 18 + class="modal-dialog-title modal-dialog-title-large" 19 + data-testid="modal-title" 20 + > 21 + Sign in 22 + </h2> 23 + <p class="modal-dialog-message" data-testid="modal-message"> 24 + Sign in to join the conversation! 25 + </p> 26 + <a 27 + href=${linkToLogin()} 28 + class="modal-dialog-button primary-button full-width" 29 + data-testid="modal-primary-button" 30 + @click=${() => dismiss()} 31 + > 32 + Sign in 33 + </a> 34 + </div> 35 + `; 36 + } 37 + }
+128
src/js/plugins/pluginModal.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + import { confirmModal } from "/js/modals/confirm.modal.js"; 3 + 4 + const pluginModals = new Map(); 5 + 6 + export function showPluginModal({ 7 + pluginRenderer, 8 + pluginId, 9 + modalId, 10 + title, 11 + content, 12 + onDismiss = () => {}, 13 + }) { 14 + let modal = pluginModals.get(`${pluginId}:${modalId}`); 15 + if (modal?.isOpen) return; 16 + 17 + if (!modal) { 18 + const dialog = document.createElement("dialog"); 19 + dialog.classList.add("modal-dialog", "plugin-modal"); 20 + dialog.dataset.pluginId = pluginId; 21 + 22 + const contentEl = document.createElement("div"); 23 + contentEl.classList.add("modal-dialog-content"); 24 + dialog.appendChild(contentEl); 25 + 26 + modal = { dialog, contentEl, isOpen: false }; 27 + 28 + function dismiss() { 29 + if (!modal.isOpen) return; 30 + modal.isOpen = false; 31 + dialog.close(); 32 + onDismiss(); 33 + } 34 + 35 + dialog.addEventListener("click", (event) => { 36 + if (event.target.tagName === "DIALOG") dismiss(); 37 + }); 38 + dialog.addEventListener("cancel", (event) => { 39 + event.preventDefault(); 40 + dismiss(); 41 + }); 42 + 43 + pluginModals.set(`${pluginId}:${modalId}`, modal); 44 + document.body.appendChild(dialog); 45 + } 46 + 47 + modal.contentEl.replaceChildren(); 48 + if (!pluginRenderer.isEmptyNode(title)) { 49 + const titleEl = pluginRenderer.createRoot().render(title); 50 + titleEl.classList.add("modal-dialog-title"); 51 + modal.contentEl.appendChild(titleEl); 52 + } 53 + if (content?.children?.length) { 54 + for (const childNode of content.children) { 55 + modal.contentEl.appendChild( 56 + pluginRenderer.createRoot().render(childNode), 57 + ); 58 + } 59 + } else if (!pluginRenderer.isEmptyNode(content)) { 60 + modal.contentEl.appendChild(pluginRenderer.createRoot().render(content)); 61 + } 62 + modal.isOpen = true; 63 + modal.dialog.showModal(); 64 + } 65 + 66 + export function hidePluginModal({ pluginId, modalId }) { 67 + const modal = pluginModals.get(`${pluginId}:${modalId}`); 68 + if (modal && modal.isOpen) { 69 + modal.isOpen = false; 70 + modal.dialog.close(); 71 + } 72 + } 73 + 74 + function permissionsListTemplate({ permissions }) { 75 + const sections = []; 76 + const fetchPatterns = permissions.fetch ?? []; 77 + if (fetchPatterns.length > 0) { 78 + sections.push(html` 79 + <div class="permission-prompt-section"> 80 + <div>Send network requests to:</div> 81 + <ul class="permission-prompt-list"> 82 + ${fetchPatterns.map( 83 + (pattern) => html`<li><code>${pattern}</code></li>`, 84 + )} 85 + </ul> 86 + </div> 87 + `); 88 + } 89 + return sections; 90 + } 91 + 92 + export async function showPluginInstallPermissionsModal({ 93 + pluginName, 94 + permissions, 95 + }) { 96 + const name = pluginName ?? "This plugin"; 97 + return confirmModal( 98 + html`<span data-testid="permission-prompt"> 99 + <span>${name} wants permission to:</span> 100 + ${permissionsListTemplate({ permissions })} 101 + </span>`, 102 + { 103 + title: "Grant permissions?", 104 + confirmButtonText: "Allow and install", 105 + }, 106 + ); 107 + } 108 + 109 + export async function showPluginUpdatePermissionsModal({ 110 + pluginName, 111 + pluginVersion, 112 + permissionsDiff, 113 + }) { 114 + const name = pluginName ?? "This plugin"; 115 + const heading = pluginVersion 116 + ? `${name} v${pluginVersion} requests new permissions:` 117 + : `${name} requests new permissions:`; 118 + return confirmModal( 119 + html`<span data-testid="permission-update-prompt"> 120 + <span>${heading}</span> 121 + ${permissionsListTemplate({ permissions: permissionsDiff })} 122 + </span>`, 123 + { 124 + title: "Grant new permissions?", 125 + confirmButtonText: "Allow and update", 126 + }, 127 + ); 128 + }
+2 -2
src/js/plugins/pluginRendering.js
··· 1 - import { showExternalLinkWarningModal } from "/js/modals.js"; 1 + import { ExternalLinkWarningModal } from "/js/modals/externalLinkWarning.modal.js"; 2 2 import "/js/components/toggle-switch.js"; 3 3 import "/js/components/plugin-profiles-list.js"; 4 4 import "/js/components/plugin-posts-feed.js"; ··· 162 162 const href = element.getAttribute("href"); 163 163 if (!href || !isExternalHref(href)) return; 164 164 event.preventDefault(); 165 - showExternalLinkWarningModal({ href }); 165 + ExternalLinkWarningModal.open({ href }); 166 166 }); 167 167 } 168 168 if (tag === "plugin-profiles-list") {
+6 -5
src/js/plugins/pluginService.js
··· 1 1 import { PluginBridge } from "/js/plugins/pluginBridge.js"; 2 - import { showPluginModal, hidePluginModal } from "/js/modals.js"; 2 + import { 3 + showPluginModal, 4 + hidePluginModal, 5 + showPluginInstallPermissionsModal, 6 + showPluginUpdatePermissionsModal, 7 + } from "/js/plugins/pluginModal.js"; 3 8 import { showPluginToast, hidePluginToast, showToast } from "/js/toasts.js"; 4 9 import { PluginRenderer } from "/js/plugins/pluginRendering.js"; 5 10 import { ··· 16 21 diffPermissions, 17 22 isEmptyPermissions, 18 23 } from "/js/plugins/pluginPermissions.js"; 19 - import { 20 - showPluginInstallPermissionsModal, 21 - showPluginUpdatePermissionsModal, 22 - } from "/js/modals.js"; 23 24 import { compareVersions, isDev, sortBy } from "/js/utils.js"; 24 25 import { Signal, SignalMap, ReactiveStore } from "/js/signals.js"; 25 26 import { EventEmitter } from "/js/eventEmitter.js";
+4 -4
src/js/postInteractionHandler.js
··· 1 1 import { hapticsImpactMedium } from "/js/haptics.js"; 2 2 import { showToast } from "/js/toasts.js"; 3 - import { confirm } from "/js/modals.js"; 3 + import { confirmModal } from "/js/modals/confirm.modal.js"; 4 4 import { trashCanIconTemplate } from "/js/templates/icons/trashCanIcon.template.js"; 5 5 6 6 export class PostInteractionHandler { ··· 73 73 74 74 async handleDeletePost(post) { 75 75 if ( 76 - !(await confirm( 76 + !(await confirmModal( 77 77 "If you remove this post, you won't be able to recover it.", 78 78 { 79 79 title: "Delete this post?", ··· 116 116 117 117 async handleHidePost(post) { 118 118 if ( 119 - !(await confirm("This post will be hidden from feeds and threads.", { 119 + !(await confirmModal("This post will be hidden from feeds and threads.", { 120 120 title: "Hide this post?", 121 121 confirmButtonText: "Hide", 122 122 })) ··· 154 154 155 155 async handleBlockAuthor(profile, doBlock) { 156 156 if (doBlock) { 157 - const confirmed = await confirm( 157 + const confirmed = await confirmModal( 158 158 "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.", 159 159 { 160 160 title: "Block Account?",
+2 -2
src/js/profileInteractionHandler.js
··· 1 1 import { hapticsImpactMedium } from "/js/haptics.js"; 2 2 import { showToast } from "/js/toasts.js"; 3 - import { confirm } from "/js/modals.js"; 3 + import { confirmModal } from "/js/modals/confirm.modal.js"; 4 4 import { getDisplayName } from "/js/dataHelpers.js"; 5 5 import "/js/components/post-notifications-dialog.js"; 6 6 ··· 54 54 55 55 async handleBlock(profile, doBlock) { 56 56 if (doBlock) { 57 - const confirmed = await confirm( 57 + const confirmed = await confirmModal( 58 58 "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.", 59 59 { 60 60 title: "Block Account?",
+2 -3
src/js/templates/automatedAccountBadge.template.js
··· 1 1 import { html } from "/js/lib/lit-html.js"; 2 2 import { isAutomatedAccount } from "/js/dataHelpers.js"; 3 3 import { automatedAccountIconTemplate } from "/js/templates/icons/automatedAccountIcon.template.js"; 4 - import { showInfoModal } from "/js/modals.js"; 4 + import { alertModal } from "/js/modals/alert.modal.js"; 5 5 6 6 export function automatedAccountBadgeTemplate({ profile }) { 7 7 if (!isAutomatedAccount(profile)) return ""; ··· 12 12 @click=${(e) => { 13 13 e.preventDefault(); 14 14 e.stopPropagation(); 15 - showInfoModal({ 15 + alertModal("This account has been marked as automated by its owner.", { 16 16 title: "Automated account", 17 - message: "This account has been marked as automated by its owner.", 18 17 confirmButtonText: "Okay", 19 18 }); 20 19 }}
+7 -7
src/js/templates/postActionBar.template.js
··· 14 14 import { bookmarkIconTemplate } from "/js/templates/icons/bookmarkIcon.template.js"; 15 15 import { getRKey, canReplyToPost } from "/js/dataHelpers.js"; 16 16 import { richTextToString } from "/js/facetHelpers.js"; 17 - import { showSignInModal } from "/js/modals.js"; 17 + import { SignInModal } from "/js/modals/signIn.modal.js"; 18 18 import "/js/components/context-menu.js"; 19 19 import "/js/components/context-menu-item.js"; 20 20 import "/js/components/context-menu-item-group.js"; ··· 270 270 ?disabled=${!canReply} 271 271 @click=${() => { 272 272 if (!isAuthenticated) { 273 - return showSignInModal(); 273 + return SignInModal.open(); 274 274 } 275 275 onClickReply(post); 276 276 }} ··· 292 292 @click=${function (e) { 293 293 e.stopPropagation(); 294 294 if (!isAuthenticated) { 295 - return showSignInModal(); 295 + return SignInModal.open(); 296 296 } 297 297 const contextMenu = this.nextElementSibling; 298 298 contextMenu.open(e.clientX, e.clientY); ··· 311 311 data-teststate=${isReposted ? "reposted" : "not-reposted"} 312 312 @click=${() => { 313 313 if (!isAuthenticated) { 314 - showSignInModal(); 314 + SignInModal.open(); 315 315 return; 316 316 } 317 317 onClickRepost(post, !isReposted); ··· 324 324 ?disabled=${!canQuotePost || !currentUser} 325 325 @click=${() => { 326 326 if (!isAuthenticated) { 327 - showSignInModal(); 327 + SignInModal.open(); 328 328 return; 329 329 } 330 330 onClickQuotePost(post); ··· 344 344 @click=${(e) => { 345 345 e.stopPropagation(); 346 346 if (!isAuthenticated) { 347 - showSignInModal(); 347 + SignInModal.open(); 348 348 return; 349 349 } 350 350 onClickLike(post, !isLiked); ··· 373 373 @click=${(e) => { 374 374 e.stopPropagation(); 375 375 if (!isAuthenticated) { 376 - showSignInModal(); 376 + SignInModal.open(); 377 377 return; 378 378 } 379 379 onClickBookmark(post, !isBookmarked);
+3 -3
src/js/templates/profileCard.template.js
··· 18 18 noop, 19 19 sortBy, 20 20 } from "/js/utils.js"; 21 - import { showSignInModal } from "/js/modals.js"; 21 + import { SignInModal } from "/js/modals/signIn.modal.js"; 22 22 import "/js/components/detected-rich-text.js"; 23 23 import { verificationBadgeTemplate } from "/js/templates/verificationBadge.template.js"; 24 24 import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBadge.template.js"; ··· 336 336 return html`<button 337 337 @click=${() => { 338 338 if (!isAuthenticated) { 339 - return showSignInModal(); 339 + return SignInModal.open(); 340 340 } 341 341 onClickSubscribe(profile, !isSubscribed, labelerInfo); 342 342 }} ··· 355 355 return html`<button 356 356 @click=${() => { 357 357 if (!isAuthenticated) { 358 - return showSignInModal(); 358 + return SignInModal.open(); 359 359 } 360 360 onClickFollow(profile, !isFollowing); 361 361 }}
+5 -6
src/js/templates/sidebar.template.js
··· 23 23 } from "/js/navigation.js"; 24 24 import "/js/components/animated-sidebar.js"; 25 25 import "/js/components/plugin-icon.js"; 26 - import { showInfoModal } from "/js/modals.js"; 26 + import { alertModal } from "/js/modals/alert.modal.js"; 27 27 28 28 function pluginSidebarItemTemplate({ entry }) { 29 29 return html` ··· 45 45 } 46 46 47 47 function showAboutModal() { 48 - showInfoModal({ 49 - title: "About Impro", 50 - message: html`<div> 48 + alertModal( 49 + html`<div> 51 50 Impro is an <strong>alternative Bluesky client</strong> built from the 52 51 ground up to be extensible and customizable. You can find more information 53 52 about the project, including the full source code, at our ··· 55 54 >GitHub repository</a 56 55 >. 57 56 </div>`, 58 - confirmButtonText: "Got it!", 59 - }); 57 + { title: "About Impro", confirmButtonText: "Got it!" }, 58 + ); 60 59 } 61 60 62 61 function sidebarNavTemplate({
+94 -5
src/js/templates/whoCanReplyBadge.template.js
··· 1 1 import { html } from "/js/lib/lit-html.js"; 2 2 import { getThreadgateAllowSettings } from "/js/dataHelpers.js"; 3 - import { showWhoCanReplyModal } from "/js/modals.js"; 3 + import { linkToProfile } from "/js/navigation.js"; 4 + import { Modal } from "/js/modals/modal.js"; 4 5 import { usersIconTemplate } from "/js/templates/icons/usersIcon.template.js"; 5 6 import { globeIconTemplate } from "/js/templates/icons/globeIcon.template.js"; 6 7 8 + function ruleTemplate({ rule, author }) { 9 + if (rule.type === "mention") { 10 + return html`mentioned users`; 11 + } 12 + if (rule.type === "followers") { 13 + return html`users following 14 + <a href=${linkToProfile(author)}>@${author.handle}</a>`; 15 + } 16 + if (rule.type === "following") { 17 + return html`users followed by 18 + <a href=${linkToProfile(author)}>@${author.handle}</a>`; 19 + } 20 + if (rule.type === "list") { 21 + if (rule.list) { 22 + return html`${rule.list.name} members`; 23 + } 24 + return html`list members`; 25 + } 26 + return html`unknown`; 27 + } 28 + 29 + function threadgateRuleTemplate({ post }) { 30 + const settings = getThreadgateAllowSettings(post); 31 + if (!Array.isArray(settings)) { 32 + if (settings.type === "everybody") { 33 + return html`Everybody can reply to this post.`; 34 + } 35 + if (settings.type === "nobody") { 36 + return html`Replies to this post are disabled.`; 37 + } 38 + } 39 + if (Array.isArray(settings)) { 40 + if (settings.some((rule) => rule.type === "unknown")) { 41 + return html`This post has an unknown type of threadgate on it. Your app 42 + may be out of date.`; 43 + } 44 + const author = post.author; 45 + const parts = []; 46 + settings.forEach((rule, i) => { 47 + if (i > 0) { 48 + if (i === settings.length - 1) { 49 + parts.push(html`, and `); 50 + } else { 51 + parts.push(html`, `); 52 + } 53 + } 54 + parts.push(ruleTemplate({ rule, author })); 55 + }); 56 + return html`Only ${parts} can reply.`; 57 + } 58 + return null; 59 + } 60 + 61 + export class WhoCanReplyModal extends Modal { 62 + get className() { 63 + return "bottom-sheet text-modal"; 64 + } 65 + 66 + get attributes() { 67 + return { "data-testid": "who-can-reply-modal" }; 68 + } 69 + 70 + render({ dismiss, props: { post } }) { 71 + const embeddingDisabled = !!post?.viewer?.embeddingDisabled; 72 + return html` 73 + <div class="modal-dialog-content"> 74 + <h2 class="modal-dialog-title" data-testid="modal-title"> 75 + Who can interact with this post? 76 + </h2> 77 + <div class="modal-dialog-message who-can-reply-body"> 78 + <span>${threadgateRuleTemplate({ post })}</span> 79 + ${embeddingDisabled 80 + ? html`<span>No one but the author can quote this post.</span>` 81 + : ""} 82 + </div> 83 + <div class="modal-dialog-buttons"> 84 + <button 85 + class="modal-dialog-button primary-button" 86 + data-testid="modal-primary-button" 87 + @click=${() => dismiss()} 88 + > 89 + OK 90 + </button> 91 + </div> 92 + </div> 93 + `; 94 + } 95 + } 96 + 7 97 export function whoCanReplyBadgeTemplate({ post }) { 8 98 const settings = getThreadgateAllowSettings(post); 9 - const embeddingDisabled = !!post?.viewer?.embeddingDisabled; 10 99 const isEverybody = !Array.isArray(settings) && settings.type === "everybody"; 11 100 let label; 12 101 let icon; ··· 25 114 type="button" 26 115 class="who-can-reply-badge" 27 116 data-testid="who-can-reply-badge" 28 - @click=${(e) => { 29 - e.stopPropagation(); 30 - showWhoCanReplyModal({ post }); 117 + @click=${(event) => { 118 + event.stopPropagation(); 119 + WhoCanReplyModal.open({ post }); 31 120 }} 32 121 > 33 122 ${icon} ${label}
+10 -7
src/js/views/settings.view.js
··· 15 15 import { linkToLogin } from "/js/navigation.js"; 16 16 import "/js/components/context-menu.js"; 17 17 import "/js/components/context-menu-item.js"; 18 - import { confirm } from "/js/modals.js"; 18 + import { confirmModal } from "/js/modals/confirm.modal.js"; 19 19 import { showToast } from "/js/toasts.js"; 20 20 import { Signal } from "/js/signals.js"; 21 21 import { userIconTemplate } from "/js/templates/icons/userIcon.template.js"; ··· 291 291 }); 292 292 }, 293 293 onRemove: async (account) => { 294 - const ok = await confirm( 294 + const ok = await confirmModal( 295 295 `Remove @${account.handle} from this device?`, 296 296 { 297 297 title: "Remove account?", ··· 333 333 data-testid="settings-sign-out" 334 334 @click=${async () => { 335 335 if ( 336 - !(await confirm("Are you sure you want to sign out?", { 337 - title: "Sign out?", 338 - confirmButtonStyle: "danger", 339 - confirmButtonText: "Sign out", 340 - })) 336 + !(await confirmModal( 337 + "Are you sure you want to sign out?", 338 + { 339 + title: "Sign out?", 340 + confirmButtonStyle: "danger", 341 + confirmButtonText: "Sign out", 342 + }, 343 + )) 341 344 ) { 342 345 return; 343 346 }
+2 -2
src/js/views/settings/communityPluginListing.view.js
··· 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; 6 6 import { showToast } from "/js/toasts.js"; 7 - import { confirm } from "/js/modals.js"; 7 + import { confirmModal } from "/js/modals/confirm.modal.js"; 8 8 import { Signal, ReactiveStore } from "/js/signals.js"; 9 9 import { PermissionsDeclinedError } from "/js/plugins/pluginService.js"; 10 10 import "/js/components/rendered-markdown.js"; ··· 70 70 async function toggleInstall(listing) { 71 71 const wasInstalled = listing.installed; 72 72 if (wasInstalled) { 73 - const confirmed = await confirm( 73 + const confirmed = await confirmModal( 74 74 `"${listing.name}" will be uninstalled and its settings will be deleted.`, 75 75 { 76 76 title: "Uninstall plugin?",
+2 -2
src/js/views/settings/mutedWords.view.js
··· 3 3 import { pageEffect } from "/js/router.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; 6 - import { confirm } from "/js/modals.js"; 6 + import { confirmModal } from "/js/modals/confirm.modal.js"; 7 7 import { differenceInHours, differenceInDays } from "/js/utils.js"; 8 8 import { Signal, ReactiveStore } from "/js/signals.js"; 9 9 import "/js/components/context-menu.js"; ··· 92 92 } 93 93 94 94 async function handleRemove(word) { 95 - const confirmed = await confirm( 95 + const confirmed = await confirmModal( 96 96 `This will delete "${word.value}" from your muted words. You can always add it back later.`, 97 97 { 98 98 title: "Are you sure?",
+2 -2
src/js/views/settings/plugins.view.js
··· 8 8 import { chevronRightIconTemplate } from "/js/templates/icons/chevronRight.template.js"; 9 9 import { trashCanIconTemplate } from "/js/templates/icons/trashCanIcon.template.js"; 10 10 import { reloadIconTemplate } from "/js/templates/icons/reloadIcon.template.js"; 11 - import { confirm } from "/js/modals.js"; 11 + import { confirmModal } from "/js/modals/confirm.modal.js"; 12 12 import { showToast } from "/js/toasts.js"; 13 13 import { Signal, SignalSet, ReactiveStore } from "/js/signals.js"; 14 14 import { PermissionsDeclinedError } from "/js/plugins/pluginService.js"; ··· 28 28 state.$updatingAll = new Signal.State(false); 29 29 30 30 async function uninstallPlugin(plugin) { 31 - const confirmed = await confirm( 31 + const confirmed = await confirmModal( 32 32 `"${plugin.name}" will be uninstalled and its settings will be deleted.`, 33 33 { 34 34 title: "Uninstall plugin?",
+1 -1
tests/e2e/specs/flows/joinLinkEmbed.test.js
··· 334 334 await page.goto(postPath); 335 335 336 336 await page.locator('[data-testid="join-link-embed-action"]').click(); 337 - const dialog = page.locator("dialog.action-modal"); 337 + const dialog = page.locator('[data-testid="join-chat-modal"]'); 338 338 await expect(dialog).toBeVisible(); 339 339 340 340 // Click at the very top-left corner (outside the dialog content)
+1 -1
tests/e2e/specs/views/home.view.test.js
··· 617 617 const replyButton = view.locator('[data-testid="reply-button"]').first(); 618 618 await replyButton.click(); 619 619 620 - const modal = page.locator("dialog.modal-dialog"); 620 + const modal = page.locator('[data-testid="sign-in-modal"]'); 621 621 await expect(modal).toBeVisible(); 622 622 await expect(modal).toContainText("Sign in"); 623 623 await expect(modal).toContainText("Sign in to join the conversation!");
-631
tests/unit/specs/modals.test.js
··· 1 - import { TestSuite } from "../testSuite.js"; 2 - import { assert, assertEquals, mock } from "../testHelpers.js"; 3 - import { 4 - showSignInModal, 5 - showInfoModal, 6 - confirm, 7 - showWhoCanReplyModal, 8 - showPluginModal as _showPluginModal, 9 - hidePluginModal, 10 - } from "/js/modals.js"; 11 - import { PluginRenderer } from "/js/plugins/pluginRendering.js"; 12 - 13 - function showPluginModal(opts) { 14 - const pluginRenderer = new PluginRenderer(null, opts.pluginId); 15 - return _showPluginModal({ pluginRenderer, ...opts }); 16 - } 17 - 18 - const t = new TestSuite("Modals"); 19 - 20 - function clearDOM() { 21 - document.body.innerHTML = ""; 22 - } 23 - 24 - t.describe("showSignInModal", (it) => { 25 - it("should create a dialog and open it", () => { 26 - clearDOM(); 27 - showSignInModal(); 28 - const dialog = document.querySelector("dialog"); 29 - assert(dialog !== null); 30 - assert(dialog.hasAttribute("open")); 31 - assert(dialog.classList.contains("modal-dialog")); 32 - assert(dialog.classList.contains("compact")); 33 - }); 34 - 35 - it("should render sign in content", () => { 36 - clearDOM(); 37 - showSignInModal(); 38 - assert(document.querySelector('[data-testid="modal-title"]') !== null); 39 - assert(document.querySelector('[data-testid="modal-message"]') !== null); 40 - const link = document.querySelector('[data-testid="modal-primary-button"]'); 41 - assert(link !== null); 42 - assert(link.getAttribute("href").startsWith("/login")); 43 - }); 44 - 45 - it("should close and remove on backdrop click", () => { 46 - clearDOM(); 47 - showSignInModal(); 48 - const dialog = document.querySelector("dialog"); 49 - dialog.dispatchEvent(new Event("click", { bubbles: true })); 50 - assert(document.querySelector("dialog") === null); 51 - }); 52 - 53 - it("should close and remove on link click", () => { 54 - clearDOM(); 55 - showSignInModal(); 56 - const link = document.querySelector('[data-testid="modal-primary-button"]'); 57 - link.click(); 58 - assert(document.querySelector("dialog") === null); 59 - }); 60 - }); 61 - 62 - // showInfoModal and confirm create fresh dialogs each time, so we can 63 - // safely clear the DOM between tests. 64 - 65 - t.describe("showInfoModal", (it) => { 66 - it("should create a dialog with info-modal class", () => { 67 - clearDOM(); 68 - showInfoModal({ title: "Info", message: "Hello" }); 69 - const dialog = document.querySelector("dialog.info-modal"); 70 - assert(dialog !== null); 71 - }); 72 - 73 - it("should render the provided title", () => { 74 - clearDOM(); 75 - showInfoModal({ title: "My Title", message: "Some message" }); 76 - const title = document.querySelector('[data-testid="modal-title"]'); 77 - assertEquals(title.textContent.trim(), "My Title"); 78 - }); 79 - 80 - it("should render the provided message", () => { 81 - clearDOM(); 82 - showInfoModal({ title: "Title", message: "Custom message" }); 83 - const message = document.querySelector('[data-testid="modal-message"]'); 84 - assertEquals(message.textContent.trim(), "Custom message"); 85 - }); 86 - 87 - it("should render a primary button by default", () => { 88 - clearDOM(); 89 - showInfoModal({ title: "Title", message: "Msg" }); 90 - const button = document.querySelector( 91 - '[data-testid="modal-primary-button"]', 92 - ); 93 - assert(button !== null); 94 - }); 95 - 96 - it("should use custom button text when provided", () => { 97 - clearDOM(); 98 - showInfoModal({ title: "T", message: "M", confirmButtonText: "Got it" }); 99 - const button = document.querySelector( 100 - '[data-testid="modal-primary-button"]', 101 - ); 102 - assertEquals(button.textContent.trim(), "Got it"); 103 - }); 104 - 105 - it("should open the dialog", () => { 106 - clearDOM(); 107 - showInfoModal({ title: "T", message: "M" }); 108 - const dialog = document.querySelector("dialog"); 109 - assert(dialog.hasAttribute("open")); 110 - }); 111 - 112 - it("should close and remove on button click", () => { 113 - clearDOM(); 114 - showInfoModal({ title: "T", message: "M" }); 115 - const button = document.querySelector( 116 - '[data-testid="modal-primary-button"]', 117 - ); 118 - button.click(); 119 - assert(document.querySelector("dialog") === null); 120 - }); 121 - 122 - it("should close and remove on backdrop click", () => { 123 - clearDOM(); 124 - showInfoModal({ title: "T", message: "M" }); 125 - const dialog = document.querySelector("dialog"); 126 - dialog.dispatchEvent(new Event("click", { bubbles: true })); 127 - assert(document.querySelector("dialog") === null); 128 - }); 129 - 130 - it("should close and remove on cancel event", () => { 131 - clearDOM(); 132 - showInfoModal({ title: "T", message: "M" }); 133 - const dialog = document.querySelector("dialog"); 134 - const cancelEvent = new Event("cancel"); 135 - cancelEvent.preventDefault = () => {}; 136 - dialog.dispatchEvent(cancelEvent); 137 - assert(document.querySelector("dialog") === null); 138 - }); 139 - }); 140 - 141 - t.describe("confirm", (it) => { 142 - it("should create a dialog in the DOM", () => { 143 - clearDOM(); 144 - confirm("Are you sure?"); 145 - const dialog = document.querySelector("dialog.confirm-modal"); 146 - assert(dialog !== null); 147 - }); 148 - 149 - it("should render the message", () => { 150 - clearDOM(); 151 - confirm("Delete this?"); 152 - const message = document.querySelector('[data-testid="modal-message"]'); 153 - assertEquals(message.textContent.trim(), "Delete this?"); 154 - }); 155 - 156 - it("should render cancel and confirm buttons", () => { 157 - clearDOM(); 158 - confirm("Sure?"); 159 - const cancelButton = document.querySelector( 160 - '[data-testid="modal-cancel-button"]', 161 - ); 162 - const confirmButton = document.querySelector( 163 - '[data-testid="modal-confirm-button"]', 164 - ); 165 - assert(cancelButton !== null); 166 - assert(confirmButton !== null); 167 - }); 168 - 169 - it("should use custom confirm button text", () => { 170 - clearDOM(); 171 - confirm("Sure?", { confirmButtonText: "Delete" }); 172 - const confirmButton = document.querySelector( 173 - '[data-testid="modal-confirm-button"]', 174 - ); 175 - assertEquals(confirmButton.textContent.trim(), "Delete"); 176 - }); 177 - 178 - it("should apply custom confirm button style", () => { 179 - clearDOM(); 180 - confirm("Sure?", { confirmButtonStyle: "danger" }); 181 - const confirmButton = document.querySelector( 182 - '[data-testid="modal-confirm-button"]', 183 - ); 184 - assert(confirmButton.classList.contains("danger-button")); 185 - }); 186 - 187 - it("should apply primary button style by default", () => { 188 - clearDOM(); 189 - confirm("Sure?"); 190 - const confirmButton = document.querySelector( 191 - '[data-testid="modal-confirm-button"]', 192 - ); 193 - assert(confirmButton.classList.contains("primary-button")); 194 - }); 195 - 196 - it("should render title when provided", () => { 197 - clearDOM(); 198 - confirm("Body text", { title: "Warning" }); 199 - const title = document.querySelector('[data-testid="modal-title"]'); 200 - assert(title !== null); 201 - assertEquals(title.textContent.trim(), "Warning"); 202 - }); 203 - 204 - it("should not render title when not provided", () => { 205 - clearDOM(); 206 - confirm("Body text"); 207 - const title = document.querySelector('[data-testid="modal-title"]'); 208 - assert(title === null); 209 - }); 210 - 211 - it("should open the dialog", () => { 212 - clearDOM(); 213 - confirm("Sure?"); 214 - const dialog = document.querySelector("dialog"); 215 - assert(dialog.hasAttribute("open")); 216 - }); 217 - 218 - it("should resolve true when confirm is clicked", async () => { 219 - clearDOM(); 220 - const result = confirm("Sure?"); 221 - document.querySelector('[data-testid="modal-confirm-button"]').click(); 222 - assertEquals(await result, true); 223 - }); 224 - 225 - it("should resolve false when cancel is clicked", async () => { 226 - clearDOM(); 227 - const result = confirm("Sure?"); 228 - document.querySelector('[data-testid="modal-cancel-button"]').click(); 229 - assertEquals(await result, false); 230 - }); 231 - 232 - it("should resolve false on backdrop click", async () => { 233 - clearDOM(); 234 - const result = confirm("Sure?"); 235 - const dialog = document.querySelector("dialog"); 236 - dialog.dispatchEvent(new Event("click", { bubbles: true })); 237 - assertEquals(await result, false); 238 - }); 239 - 240 - it("should resolve false on cancel event", async () => { 241 - clearDOM(); 242 - const result = confirm("Sure?"); 243 - const dialog = document.querySelector("dialog"); 244 - const cancelEvent = new Event("cancel"); 245 - cancelEvent.preventDefault = () => {}; 246 - dialog.dispatchEvent(cancelEvent); 247 - assertEquals(await result, false); 248 - }); 249 - 250 - it("should remove dialog from DOM after confirm", async () => { 251 - clearDOM(); 252 - const result = confirm("Sure?"); 253 - document.querySelector('[data-testid="modal-confirm-button"]').click(); 254 - await result; 255 - assert(document.querySelector("dialog") === null); 256 - }); 257 - 258 - it("should remove dialog from DOM after cancel", async () => { 259 - clearDOM(); 260 - const result = confirm("Sure?"); 261 - document.querySelector('[data-testid="modal-cancel-button"]').click(); 262 - await result; 263 - assert(document.querySelector("dialog") === null); 264 - }); 265 - }); 266 - 267 - t.describe("showWhoCanReplyModal", (it) => { 268 - const everybodyPost = { author: { handle: "alice.test" } }; 269 - const nobodyPost = { 270 - author: { handle: "alice.test" }, 271 - threadgate: { record: { allow: [] } }, 272 - }; 273 - const followersPost = { 274 - author: { handle: "alice.test" }, 275 - threadgate: { 276 - record: { 277 - allow: [{ $type: "app.bsky.feed.threadgate#followerRule" }], 278 - }, 279 - }, 280 - }; 281 - const mentionAndFollowingPost = { 282 - author: { handle: "alice.test" }, 283 - threadgate: { 284 - record: { 285 - allow: [ 286 - { $type: "app.bsky.feed.threadgate#mentionRule" }, 287 - { $type: "app.bsky.feed.threadgate#followingRule" }, 288 - ], 289 - }, 290 - }, 291 - }; 292 - 293 - it("should create a dialog with the who-can-reply testid", () => { 294 - clearDOM(); 295 - showWhoCanReplyModal({ post: everybodyPost }); 296 - const dialog = document.querySelector("dialog"); 297 - assert(dialog !== null); 298 - assertEquals(dialog.dataset.testid, "who-can-reply-modal"); 299 - assert(dialog.classList.contains("info-modal")); 300 - assert(dialog.hasAttribute("open")); 301 - }); 302 - 303 - it("should render the title", () => { 304 - clearDOM(); 305 - showWhoCanReplyModal({ post: everybodyPost }); 306 - const title = document.querySelector('[data-testid="modal-title"]'); 307 - assert(title !== null); 308 - }); 309 - 310 - it("should render everybody message when no threadgate", () => { 311 - clearDOM(); 312 - showWhoCanReplyModal({ post: everybodyPost }); 313 - const body = document.querySelector(".who-can-reply-body"); 314 - assert(body.textContent.includes("Everybody can reply to this post.")); 315 - }); 316 - 317 - it("should render nobody message when allow is empty", () => { 318 - clearDOM(); 319 - showWhoCanReplyModal({ post: nobodyPost }); 320 - const body = document.querySelector(".who-can-reply-body"); 321 - assert(body.textContent.includes("Replies to this post are disabled.")); 322 - }); 323 - 324 - it("should render followers rule", () => { 325 - clearDOM(); 326 - showWhoCanReplyModal({ post: followersPost }); 327 - const body = document.querySelector(".who-can-reply-body"); 328 - assert(body.textContent.includes("Only")); 329 - assert(body.textContent.includes("users following")); 330 - assert(body.textContent.includes("@alice.test")); 331 - assert(body.textContent.includes("can reply.")); 332 - }); 333 - 334 - it("should join multiple rules with 'and'", () => { 335 - clearDOM(); 336 - showWhoCanReplyModal({ post: mentionAndFollowingPost }); 337 - const body = document.querySelector(".who-can-reply-body"); 338 - assert(body.textContent.includes("mentioned users")); 339 - assert(body.textContent.includes(", and ")); 340 - assert(body.textContent.includes("users followed by")); 341 - }); 342 - 343 - it("should not show quote message when embedding is enabled", () => { 344 - clearDOM(); 345 - showWhoCanReplyModal({ post: everybodyPost }); 346 - const body = document.querySelector(".who-can-reply-body"); 347 - assert(!body.textContent.includes("quote this post")); 348 - }); 349 - 350 - it("should show quote message when embedding is disabled", () => { 351 - clearDOM(); 352 - showWhoCanReplyModal({ 353 - post: { ...everybodyPost, viewer: { embeddingDisabled: true } }, 354 - }); 355 - const body = document.querySelector(".who-can-reply-body"); 356 - assert( 357 - body.textContent.includes("No one but the author can quote this post."), 358 - ); 359 - }); 360 - 361 - it("should close and remove on primary button click", () => { 362 - clearDOM(); 363 - showWhoCanReplyModal({ post: everybodyPost }); 364 - const button = document.querySelector( 365 - '[data-testid="modal-primary-button"]', 366 - ); 367 - button.click(); 368 - assert(document.querySelector("dialog") === null); 369 - }); 370 - 371 - it("should close and remove on backdrop click", () => { 372 - clearDOM(); 373 - showWhoCanReplyModal({ post: everybodyPost }); 374 - const dialog = document.querySelector("dialog"); 375 - dialog.dispatchEvent(new Event("click", { bubbles: true })); 376 - assert(document.querySelector("dialog") === null); 377 - }); 378 - 379 - it("should close and remove on cancel event", () => { 380 - clearDOM(); 381 - showWhoCanReplyModal({ post: everybodyPost }); 382 - const dialog = document.querySelector("dialog"); 383 - const cancelEvent = new Event("cancel"); 384 - cancelEvent.preventDefault = () => {}; 385 - dialog.dispatchEvent(cancelEvent); 386 - assert(document.querySelector("dialog") === null); 387 - }); 388 - }); 389 - 390 - let pluginModalCounter = 0; 391 - function uniqueModalId(prefix) { 392 - pluginModalCounter += 1; 393 - return `${prefix}-${pluginModalCounter}`; 394 - } 395 - 396 - t.describe("showPluginModal", (it) => { 397 - it("should create a dialog with plugin-modal class and pluginId dataset", () => { 398 - clearDOM(); 399 - showPluginModal({ 400 - pluginId: "test.plugin", 401 - modalId: uniqueModalId("create"), 402 - title: { tag: "span", text: "Hello" }, 403 - content: { tag: "div", text: "Body" }, 404 - }); 405 - const dialog = document.querySelector("dialog.plugin-modal"); 406 - assert(dialog !== null); 407 - assertEquals(dialog.dataset.pluginId, "test.plugin"); 408 - assert(dialog.classList.contains("modal-dialog")); 409 - assert(dialog.hasAttribute("open")); 410 - }); 411 - 412 - it("should render the title with the modal-dialog-title class", () => { 413 - clearDOM(); 414 - showPluginModal({ 415 - pluginId: "p", 416 - modalId: uniqueModalId("title"), 417 - title: { tag: "span", text: "My Title" }, 418 - content: { tag: "div", text: "Body" }, 419 - }); 420 - const title = document.querySelector(".modal-dialog-title"); 421 - assert(title !== null); 422 - assertEquals(title.textContent, "My Title"); 423 - }); 424 - 425 - it("should skip the title when it is empty", () => { 426 - clearDOM(); 427 - showPluginModal({ 428 - pluginId: "p", 429 - modalId: uniqueModalId("no-title"), 430 - title: { tag: "span", text: "" }, 431 - content: { tag: "div", text: "Body only" }, 432 - }); 433 - const title = document.querySelector(".modal-dialog-title"); 434 - assert(title === null); 435 - }); 436 - 437 - it("should render content children when content has children", () => { 438 - clearDOM(); 439 - showPluginModal({ 440 - pluginId: "p", 441 - modalId: uniqueModalId("children"), 442 - title: { tag: "span", text: "T" }, 443 - content: { 444 - tag: "div", 445 - children: [ 446 - { tag: "p", text: "First" }, 447 - { tag: "p", text: "Second" }, 448 - ], 449 - }, 450 - }); 451 - const paragraphs = document.querySelectorAll(".modal-dialog-content > p"); 452 - assertEquals(paragraphs.length, 2); 453 - assertEquals(paragraphs[0].textContent, "First"); 454 - assertEquals(paragraphs[1].textContent, "Second"); 455 - }); 456 - 457 - it("should render the content node directly when it has no children", () => { 458 - clearDOM(); 459 - showPluginModal({ 460 - pluginId: "p", 461 - modalId: uniqueModalId("single"), 462 - title: { tag: "span", text: "T" }, 463 - content: { tag: "p", text: "Single body" }, 464 - }); 465 - const body = document.querySelector(".modal-dialog-content > p"); 466 - assert(body !== null); 467 - assertEquals(body.textContent, "Single body"); 468 - }); 469 - 470 - it("should reuse the existing dialog and replace its content on a second call", () => { 471 - clearDOM(); 472 - const pluginId = "reuse.plugin"; 473 - const modalId = uniqueModalId("reuse"); 474 - showPluginModal({ 475 - pluginId, 476 - modalId, 477 - title: { tag: "span", text: "First Title" }, 478 - content: { tag: "p", text: "First body" }, 479 - }); 480 - hidePluginModal({ pluginId, modalId }); 481 - showPluginModal({ 482 - pluginId, 483 - modalId, 484 - title: { tag: "span", text: "Second Title" }, 485 - content: { tag: "p", text: "Second body" }, 486 - }); 487 - const dialogs = document.querySelectorAll("dialog.plugin-modal"); 488 - assertEquals(dialogs.length, 1); 489 - const title = document.querySelector(".modal-dialog-title"); 490 - assertEquals(title.textContent, "Second Title"); 491 - const body = document.querySelector(".modal-dialog-content > p"); 492 - assertEquals(body.textContent, "Second body"); 493 - assert(dialogs[0].hasAttribute("open")); 494 - }); 495 - 496 - it("should be a no-op when called with the same key while already open", () => { 497 - clearDOM(); 498 - const pluginId = "noop.plugin"; 499 - const modalId = uniqueModalId("noop"); 500 - showPluginModal({ 501 - pluginId, 502 - modalId, 503 - title: { tag: "span", text: "Original" }, 504 - content: { tag: "p", text: "Original body" }, 505 - }); 506 - showPluginModal({ 507 - pluginId, 508 - modalId, 509 - title: { tag: "span", text: "Replaced" }, 510 - content: { tag: "p", text: "Replaced body" }, 511 - }); 512 - const title = document.querySelector(".modal-dialog-title"); 513 - assertEquals(title.textContent, "Original"); 514 - hidePluginModal({ pluginId, modalId }); 515 - }); 516 - 517 - it("should invoke onDismiss when dismissed via backdrop click", () => { 518 - clearDOM(); 519 - const onDismiss = mock(); 520 - showPluginModal({ 521 - pluginId: "backdrop.plugin", 522 - modalId: uniqueModalId("backdrop"), 523 - title: { tag: "span", text: "T" }, 524 - content: { tag: "p", text: "B" }, 525 - onDismiss, 526 - }); 527 - const dialog = document.querySelector("dialog.plugin-modal"); 528 - dialog.dispatchEvent(new Event("click", { bubbles: true })); 529 - assert(!dialog.hasAttribute("open")); 530 - assertEquals(onDismiss.calls.length, 1); 531 - }); 532 - 533 - it("should invoke onDismiss when dismissed via cancel event", () => { 534 - clearDOM(); 535 - const onDismiss = mock(); 536 - showPluginModal({ 537 - pluginId: "cancel.plugin", 538 - modalId: uniqueModalId("cancel"), 539 - title: { tag: "span", text: "T" }, 540 - content: { tag: "p", text: "B" }, 541 - onDismiss, 542 - }); 543 - const dialog = document.querySelector("dialog.plugin-modal"); 544 - const cancelEvent = new Event("cancel"); 545 - cancelEvent.preventDefault = () => {}; 546 - dialog.dispatchEvent(cancelEvent); 547 - assert(!dialog.hasAttribute("open")); 548 - assertEquals(onDismiss.calls.length, 1); 549 - }); 550 - 551 - it("should not require an onDismiss callback", () => { 552 - clearDOM(); 553 - showPluginModal({ 554 - pluginId: "no-cb.plugin", 555 - modalId: uniqueModalId("no-cb"), 556 - title: { tag: "span", text: "T" }, 557 - content: { tag: "p", text: "B" }, 558 - }); 559 - const dialog = document.querySelector("dialog.plugin-modal"); 560 - dialog.dispatchEvent(new Event("click", { bubbles: true })); 561 - assert(!dialog.hasAttribute("open")); 562 - }); 563 - 564 - it("should isolate modals by pluginId/modalId key", () => { 565 - clearDOM(); 566 - const modalIdA = uniqueModalId("isoA"); 567 - const modalIdB = uniqueModalId("isoB"); 568 - showPluginModal({ 569 - pluginId: "iso.plugin", 570 - modalId: modalIdA, 571 - title: { tag: "span", text: "A" }, 572 - content: { tag: "p", text: "A body" }, 573 - }); 574 - showPluginModal({ 575 - pluginId: "iso.plugin", 576 - modalId: modalIdB, 577 - title: { tag: "span", text: "B" }, 578 - content: { tag: "p", text: "B body" }, 579 - }); 580 - const dialogs = document.querySelectorAll("dialog.plugin-modal"); 581 - assertEquals(dialogs.length, 2); 582 - hidePluginModal({ pluginId: "iso.plugin", modalId: modalIdA }); 583 - hidePluginModal({ pluginId: "iso.plugin", modalId: modalIdB }); 584 - }); 585 - }); 586 - 587 - t.describe("hidePluginModal", (it) => { 588 - it("should close the dialog without invoking onDismiss", () => { 589 - clearDOM(); 590 - const onDismiss = mock(); 591 - const pluginId = "hide.plugin"; 592 - const modalId = uniqueModalId("hide"); 593 - showPluginModal({ 594 - pluginId, 595 - modalId, 596 - title: { tag: "span", text: "T" }, 597 - content: { tag: "p", text: "B" }, 598 - onDismiss, 599 - }); 600 - const dialog = document.querySelector("dialog.plugin-modal"); 601 - assert(dialog.hasAttribute("open")); 602 - hidePluginModal({ pluginId, modalId }); 603 - assert(!dialog.hasAttribute("open")); 604 - assertEquals(onDismiss.calls.length, 0); 605 - }); 606 - 607 - it("should be a no-op when no modal exists for the key", () => { 608 - clearDOM(); 609 - hidePluginModal({ pluginId: "missing.plugin", modalId: "missing-modal" }); 610 - assert(document.querySelector("dialog") === null); 611 - }); 612 - 613 - it("should be a no-op when the modal is already closed", () => { 614 - clearDOM(); 615 - const onDismiss = mock(); 616 - const pluginId = "double-hide.plugin"; 617 - const modalId = uniqueModalId("double-hide"); 618 - showPluginModal({ 619 - pluginId, 620 - modalId, 621 - title: { tag: "span", text: "T" }, 622 - content: { tag: "p", text: "B" }, 623 - onDismiss, 624 - }); 625 - hidePluginModal({ pluginId, modalId }); 626 - hidePluginModal({ pluginId, modalId }); 627 - assertEquals(onDismiss.calls.length, 0); 628 - }); 629 - }); 630 - 631 - await t.run();
+78
tests/unit/specs/modals/alert.modal.test.js
··· 1 + import { TestSuite } from "../../testSuite.js"; 2 + import { assert, assertEquals } from "../../testHelpers.js"; 3 + import { alertModal } from "/js/modals/alert.modal.js"; 4 + 5 + const t = new TestSuite("alertModal"); 6 + 7 + t.describe("alertModal", (it, { beforeEach }) => { 8 + beforeEach(() => { 9 + document.body.innerHTML = ""; 10 + }); 11 + 12 + it("should create a dialog with the alert-modal testid", () => { 13 + alertModal("Hello", { title: "Info" }); 14 + const dialog = document.querySelector('[data-testid="alert-modal"]'); 15 + assert(dialog !== null); 16 + }); 17 + 18 + it("should render the provided title", () => { 19 + alertModal("Some message", { title: "My Title" }); 20 + const title = document.querySelector('[data-testid="modal-title"]'); 21 + assertEquals(title.textContent.trim(), "My Title"); 22 + }); 23 + 24 + it("should render the provided message", () => { 25 + alertModal("Custom message", { title: "Title" }); 26 + const message = document.querySelector('[data-testid="modal-message"]'); 27 + assertEquals(message.textContent.trim(), "Custom message"); 28 + }); 29 + 30 + it("should render a primary button by default", () => { 31 + alertModal("Msg", { title: "Title" }); 32 + const button = document.querySelector( 33 + '[data-testid="modal-primary-button"]', 34 + ); 35 + assert(button !== null); 36 + }); 37 + 38 + it("should use custom button text when provided", () => { 39 + alertModal("M", { title: "T", confirmButtonText: "Got it" }); 40 + const button = document.querySelector( 41 + '[data-testid="modal-primary-button"]', 42 + ); 43 + assertEquals(button.textContent.trim(), "Got it"); 44 + }); 45 + 46 + it("should open the dialog", () => { 47 + alertModal("M", { title: "T" }); 48 + const dialog = document.querySelector('[data-testid="alert-modal"]'); 49 + assert(dialog.hasAttribute("open")); 50 + }); 51 + 52 + it("should close and remove on button click", () => { 53 + alertModal("M", { title: "T" }); 54 + const button = document.querySelector( 55 + '[data-testid="modal-primary-button"]', 56 + ); 57 + button.click(); 58 + assert(document.querySelector('[data-testid="alert-modal"]') === null); 59 + }); 60 + 61 + it("should close and remove on backdrop click", () => { 62 + alertModal("M", { title: "T" }); 63 + const dialog = document.querySelector('[data-testid="alert-modal"]'); 64 + dialog.dispatchEvent(new Event("click", { bubbles: true })); 65 + assert(document.querySelector('[data-testid="alert-modal"]') === null); 66 + }); 67 + 68 + it("should close and remove on cancel event", () => { 69 + alertModal("M", { title: "T" }); 70 + const dialog = document.querySelector('[data-testid="alert-modal"]'); 71 + const cancelEvent = new Event("cancel"); 72 + cancelEvent.preventDefault = () => {}; 73 + dialog.dispatchEvent(cancelEvent); 74 + assert(document.querySelector('[data-testid="alert-modal"]') === null); 75 + }); 76 + }); 77 + 78 + await t.run();
+122
tests/unit/specs/modals/confirm.modal.test.js
··· 1 + import { TestSuite } from "../../testSuite.js"; 2 + import { assert, assertEquals } from "../../testHelpers.js"; 3 + import { confirmModal } from "/js/modals/confirm.modal.js"; 4 + 5 + const t = new TestSuite("confirmModal"); 6 + 7 + t.describe("confirmModal", (it, { beforeEach }) => { 8 + beforeEach(() => { 9 + document.body.innerHTML = ""; 10 + }); 11 + 12 + it("should create a dialog in the DOM", () => { 13 + confirmModal("Are you sure?"); 14 + const dialog = document.querySelector('[data-testid="confirm-modal"]'); 15 + assert(dialog !== null); 16 + }); 17 + 18 + it("should render the message", () => { 19 + confirmModal("Delete this?"); 20 + const message = document.querySelector('[data-testid="modal-message"]'); 21 + assertEquals(message.textContent.trim(), "Delete this?"); 22 + }); 23 + 24 + it("should render cancel and confirm buttons", () => { 25 + confirmModal("Sure?"); 26 + const cancelButton = document.querySelector( 27 + '[data-testid="modal-cancel-button"]', 28 + ); 29 + const confirmButton = document.querySelector( 30 + '[data-testid="modal-confirm-button"]', 31 + ); 32 + assert(cancelButton !== null); 33 + assert(confirmButton !== null); 34 + }); 35 + 36 + it("should use custom confirm button text", () => { 37 + confirmModal("Sure?", { confirmButtonText: "Delete" }); 38 + const confirmButton = document.querySelector( 39 + '[data-testid="modal-confirm-button"]', 40 + ); 41 + assertEquals(confirmButton.textContent.trim(), "Delete"); 42 + }); 43 + 44 + it("should apply custom confirm button style", () => { 45 + confirmModal("Sure?", { confirmButtonStyle: "danger" }); 46 + const confirmButton = document.querySelector( 47 + '[data-testid="modal-confirm-button"]', 48 + ); 49 + assert(confirmButton.classList.contains("danger-button")); 50 + }); 51 + 52 + it("should apply primary button style by default", () => { 53 + confirmModal("Sure?"); 54 + const confirmButton = document.querySelector( 55 + '[data-testid="modal-confirm-button"]', 56 + ); 57 + assert(confirmButton.classList.contains("primary-button")); 58 + }); 59 + 60 + it("should render title when provided", () => { 61 + confirmModal("Body text", { title: "Warning" }); 62 + const title = document.querySelector('[data-testid="modal-title"]'); 63 + assert(title !== null); 64 + assertEquals(title.textContent.trim(), "Warning"); 65 + }); 66 + 67 + it("should not render title when not provided", () => { 68 + confirmModal("Body text"); 69 + const title = document.querySelector('[data-testid="modal-title"]'); 70 + assert(title === null); 71 + }); 72 + 73 + it("should open the dialog", () => { 74 + confirmModal("Sure?"); 75 + const dialog = document.querySelector('[data-testid="confirm-modal"]'); 76 + assert(dialog.hasAttribute("open")); 77 + }); 78 + 79 + it("should resolve true when confirm is clicked", async () => { 80 + const result = confirmModal("Sure?"); 81 + document.querySelector('[data-testid="modal-confirm-button"]').click(); 82 + assertEquals(await result, true); 83 + }); 84 + 85 + it("should resolve false when cancel is clicked", async () => { 86 + const result = confirmModal("Sure?"); 87 + document.querySelector('[data-testid="modal-cancel-button"]').click(); 88 + assertEquals(await result, false); 89 + }); 90 + 91 + it("should resolve false on backdrop click", async () => { 92 + const result = confirmModal("Sure?"); 93 + const dialog = document.querySelector('[data-testid="confirm-modal"]'); 94 + dialog.dispatchEvent(new Event("click", { bubbles: true })); 95 + assertEquals(await result, false); 96 + }); 97 + 98 + it("should resolve false on cancel event", async () => { 99 + const result = confirmModal("Sure?"); 100 + const dialog = document.querySelector('[data-testid="confirm-modal"]'); 101 + const cancelEvent = new Event("cancel"); 102 + cancelEvent.preventDefault = () => {}; 103 + dialog.dispatchEvent(cancelEvent); 104 + assertEquals(await result, false); 105 + }); 106 + 107 + it("should remove dialog from DOM after confirm", async () => { 108 + const result = confirmModal("Sure?"); 109 + document.querySelector('[data-testid="modal-confirm-button"]').click(); 110 + await result; 111 + assert(document.querySelector('[data-testid="confirm-modal"]') === null); 112 + }); 113 + 114 + it("should remove dialog from DOM after cancel", async () => { 115 + const result = confirmModal("Sure?"); 116 + document.querySelector('[data-testid="modal-cancel-button"]').click(); 117 + await result; 118 + assert(document.querySelector('[data-testid="confirm-modal"]') === null); 119 + }); 120 + }); 121 + 122 + await t.run();
+43
tests/unit/specs/modals/signIn.modal.test.js
··· 1 + import { TestSuite } from "../../testSuite.js"; 2 + import { assert } from "../../testHelpers.js"; 3 + import { SignInModal } from "/js/modals/signIn.modal.js"; 4 + 5 + const t = new TestSuite("SignInModal"); 6 + 7 + t.describe("SignInModal", (it, { beforeEach }) => { 8 + beforeEach(() => { 9 + document.body.innerHTML = ""; 10 + }); 11 + 12 + it("should create a dialog and open it", () => { 13 + SignInModal.open(); 14 + const dialog = document.querySelector('[data-testid="sign-in-modal"]'); 15 + assert(dialog !== null); 16 + assert(dialog.hasAttribute("open")); 17 + }); 18 + 19 + it("should render sign in content", () => { 20 + SignInModal.open(); 21 + assert(document.querySelector('[data-testid="modal-title"]') !== null); 22 + assert(document.querySelector('[data-testid="modal-message"]') !== null); 23 + const link = document.querySelector('[data-testid="modal-primary-button"]'); 24 + assert(link !== null); 25 + assert(link.getAttribute("href").startsWith("/login")); 26 + }); 27 + 28 + it("should close and remove on backdrop click", () => { 29 + SignInModal.open(); 30 + const dialog = document.querySelector('[data-testid="sign-in-modal"]'); 31 + dialog.dispatchEvent(new Event("click", { bubbles: true })); 32 + assert(document.querySelector('[data-testid="sign-in-modal"]') === null); 33 + }); 34 + 35 + it("should close and remove on link click", () => { 36 + SignInModal.open(); 37 + const link = document.querySelector('[data-testid="modal-primary-button"]'); 38 + link.click(); 39 + assert(document.querySelector('[data-testid="sign-in-modal"]') === null); 40 + }); 41 + }); 42 + 43 + await t.run();
+261
tests/unit/specs/plugins/pluginModal.test.js
··· 1 + import { TestSuite } from "../../testSuite.js"; 2 + import { assert, assertEquals, mock } from "../../testHelpers.js"; 3 + import { 4 + showPluginModal as _showPluginModal, 5 + hidePluginModal, 6 + } from "/js/plugins/pluginModal.js"; 7 + import { PluginRenderer } from "/js/plugins/pluginRendering.js"; 8 + 9 + function showPluginModal(opts) { 10 + const pluginRenderer = new PluginRenderer(null, opts.pluginId); 11 + return _showPluginModal({ pluginRenderer, ...opts }); 12 + } 13 + 14 + const t = new TestSuite("pluginModal"); 15 + 16 + function clearDOM() { 17 + document.body.innerHTML = ""; 18 + } 19 + 20 + let pluginModalCounter = 0; 21 + function uniqueModalId(prefix) { 22 + pluginModalCounter += 1; 23 + return `${prefix}-${pluginModalCounter}`; 24 + } 25 + 26 + t.describe("showPluginModal", (it) => { 27 + it("should create a dialog with plugin-modal class and pluginId dataset", () => { 28 + clearDOM(); 29 + showPluginModal({ 30 + pluginId: "test.plugin", 31 + modalId: uniqueModalId("create"), 32 + title: { tag: "span", text: "Hello" }, 33 + content: { tag: "div", text: "Body" }, 34 + }); 35 + const dialog = document.querySelector("dialog.plugin-modal"); 36 + assert(dialog !== null); 37 + assertEquals(dialog.dataset.pluginId, "test.plugin"); 38 + assert(dialog.classList.contains("modal-dialog")); 39 + assert(dialog.hasAttribute("open")); 40 + }); 41 + 42 + it("should render the title with the modal-dialog-title class", () => { 43 + clearDOM(); 44 + showPluginModal({ 45 + pluginId: "p", 46 + modalId: uniqueModalId("title"), 47 + title: { tag: "span", text: "My Title" }, 48 + content: { tag: "div", text: "Body" }, 49 + }); 50 + const title = document.querySelector(".modal-dialog-title"); 51 + assert(title !== null); 52 + assertEquals(title.textContent, "My Title"); 53 + }); 54 + 55 + it("should skip the title when it is empty", () => { 56 + clearDOM(); 57 + showPluginModal({ 58 + pluginId: "p", 59 + modalId: uniqueModalId("no-title"), 60 + title: { tag: "span", text: "" }, 61 + content: { tag: "div", text: "Body only" }, 62 + }); 63 + const title = document.querySelector(".modal-dialog-title"); 64 + assert(title === null); 65 + }); 66 + 67 + it("should render content children when content has children", () => { 68 + clearDOM(); 69 + showPluginModal({ 70 + pluginId: "p", 71 + modalId: uniqueModalId("children"), 72 + title: { tag: "span", text: "T" }, 73 + content: { 74 + tag: "div", 75 + children: [ 76 + { tag: "p", text: "First" }, 77 + { tag: "p", text: "Second" }, 78 + ], 79 + }, 80 + }); 81 + const paragraphs = document.querySelectorAll(".modal-dialog-content > p"); 82 + assertEquals(paragraphs.length, 2); 83 + assertEquals(paragraphs[0].textContent, "First"); 84 + assertEquals(paragraphs[1].textContent, "Second"); 85 + }); 86 + 87 + it("should render the content node directly when it has no children", () => { 88 + clearDOM(); 89 + showPluginModal({ 90 + pluginId: "p", 91 + modalId: uniqueModalId("single"), 92 + title: { tag: "span", text: "T" }, 93 + content: { tag: "p", text: "Single body" }, 94 + }); 95 + const body = document.querySelector(".modal-dialog-content > p"); 96 + assert(body !== null); 97 + assertEquals(body.textContent, "Single body"); 98 + }); 99 + 100 + it("should reuse the existing dialog and replace its content on a second call", () => { 101 + clearDOM(); 102 + const pluginId = "reuse.plugin"; 103 + const modalId = uniqueModalId("reuse"); 104 + showPluginModal({ 105 + pluginId, 106 + modalId, 107 + title: { tag: "span", text: "First Title" }, 108 + content: { tag: "p", text: "First body" }, 109 + }); 110 + hidePluginModal({ pluginId, modalId }); 111 + showPluginModal({ 112 + pluginId, 113 + modalId, 114 + title: { tag: "span", text: "Second Title" }, 115 + content: { tag: "p", text: "Second body" }, 116 + }); 117 + const dialogs = document.querySelectorAll("dialog.plugin-modal"); 118 + assertEquals(dialogs.length, 1); 119 + const title = document.querySelector(".modal-dialog-title"); 120 + assertEquals(title.textContent, "Second Title"); 121 + const body = document.querySelector(".modal-dialog-content > p"); 122 + assertEquals(body.textContent, "Second body"); 123 + assert(dialogs[0].hasAttribute("open")); 124 + }); 125 + 126 + it("should be a no-op when called with the same key while already open", () => { 127 + clearDOM(); 128 + const pluginId = "noop.plugin"; 129 + const modalId = uniqueModalId("noop"); 130 + showPluginModal({ 131 + pluginId, 132 + modalId, 133 + title: { tag: "span", text: "Original" }, 134 + content: { tag: "p", text: "Original body" }, 135 + }); 136 + showPluginModal({ 137 + pluginId, 138 + modalId, 139 + title: { tag: "span", text: "Replaced" }, 140 + content: { tag: "p", text: "Replaced body" }, 141 + }); 142 + const title = document.querySelector(".modal-dialog-title"); 143 + assertEquals(title.textContent, "Original"); 144 + hidePluginModal({ pluginId, modalId }); 145 + }); 146 + 147 + it("should invoke onDismiss when dismissed via backdrop click", () => { 148 + clearDOM(); 149 + const onDismiss = mock(); 150 + showPluginModal({ 151 + pluginId: "backdrop.plugin", 152 + modalId: uniqueModalId("backdrop"), 153 + title: { tag: "span", text: "T" }, 154 + content: { tag: "p", text: "B" }, 155 + onDismiss, 156 + }); 157 + const dialog = document.querySelector("dialog.plugin-modal"); 158 + dialog.dispatchEvent(new Event("click", { bubbles: true })); 159 + assert(!dialog.hasAttribute("open")); 160 + assertEquals(onDismiss.calls.length, 1); 161 + }); 162 + 163 + it("should invoke onDismiss when dismissed via cancel event", () => { 164 + clearDOM(); 165 + const onDismiss = mock(); 166 + showPluginModal({ 167 + pluginId: "cancel.plugin", 168 + modalId: uniqueModalId("cancel"), 169 + title: { tag: "span", text: "T" }, 170 + content: { tag: "p", text: "B" }, 171 + onDismiss, 172 + }); 173 + const dialog = document.querySelector("dialog.plugin-modal"); 174 + const cancelEvent = new Event("cancel"); 175 + cancelEvent.preventDefault = () => {}; 176 + dialog.dispatchEvent(cancelEvent); 177 + assert(!dialog.hasAttribute("open")); 178 + assertEquals(onDismiss.calls.length, 1); 179 + }); 180 + 181 + it("should not require an onDismiss callback", () => { 182 + clearDOM(); 183 + showPluginModal({ 184 + pluginId: "no-cb.plugin", 185 + modalId: uniqueModalId("no-cb"), 186 + title: { tag: "span", text: "T" }, 187 + content: { tag: "p", text: "B" }, 188 + }); 189 + const dialog = document.querySelector("dialog.plugin-modal"); 190 + dialog.dispatchEvent(new Event("click", { bubbles: true })); 191 + assert(!dialog.hasAttribute("open")); 192 + }); 193 + 194 + it("should isolate modals by pluginId/modalId key", () => { 195 + clearDOM(); 196 + const modalIdA = uniqueModalId("isoA"); 197 + const modalIdB = uniqueModalId("isoB"); 198 + showPluginModal({ 199 + pluginId: "iso.plugin", 200 + modalId: modalIdA, 201 + title: { tag: "span", text: "A" }, 202 + content: { tag: "p", text: "A body" }, 203 + }); 204 + showPluginModal({ 205 + pluginId: "iso.plugin", 206 + modalId: modalIdB, 207 + title: { tag: "span", text: "B" }, 208 + content: { tag: "p", text: "B body" }, 209 + }); 210 + const dialogs = document.querySelectorAll("dialog.plugin-modal"); 211 + assertEquals(dialogs.length, 2); 212 + hidePluginModal({ pluginId: "iso.plugin", modalId: modalIdA }); 213 + hidePluginModal({ pluginId: "iso.plugin", modalId: modalIdB }); 214 + }); 215 + }); 216 + 217 + t.describe("hidePluginModal", (it) => { 218 + it("should close the dialog without invoking onDismiss", () => { 219 + clearDOM(); 220 + const onDismiss = mock(); 221 + const pluginId = "hide.plugin"; 222 + const modalId = uniqueModalId("hide"); 223 + showPluginModal({ 224 + pluginId, 225 + modalId, 226 + title: { tag: "span", text: "T" }, 227 + content: { tag: "p", text: "B" }, 228 + onDismiss, 229 + }); 230 + const dialog = document.querySelector("dialog.plugin-modal"); 231 + assert(dialog.hasAttribute("open")); 232 + hidePluginModal({ pluginId, modalId }); 233 + assert(!dialog.hasAttribute("open")); 234 + assertEquals(onDismiss.calls.length, 0); 235 + }); 236 + 237 + it("should be a no-op when no modal exists for the key", () => { 238 + clearDOM(); 239 + hidePluginModal({ pluginId: "missing.plugin", modalId: "missing-modal" }); 240 + assert(document.querySelector("dialog") === null); 241 + }); 242 + 243 + it("should be a no-op when the modal is already closed", () => { 244 + clearDOM(); 245 + const onDismiss = mock(); 246 + const pluginId = "double-hide.plugin"; 247 + const modalId = uniqueModalId("double-hide"); 248 + showPluginModal({ 249 + pluginId, 250 + modalId, 251 + title: { tag: "span", text: "T" }, 252 + content: { tag: "p", text: "B" }, 253 + onDismiss, 254 + }); 255 + hidePluginModal({ pluginId, modalId }); 256 + hidePluginModal({ pluginId, modalId }); 257 + assertEquals(onDismiss.calls.length, 0); 258 + }); 259 + }); 260 + 261 + await t.run();
+1 -1
tests/unit/specs/templates/automatedAccountBadge.template.test.js
··· 49 49 render(result, container); 50 50 const badge = container.querySelector(".automated-account-badge"); 51 51 badge.click(); 52 - const dialog = document.querySelector("dialog.info-modal"); 52 + const dialog = document.querySelector('[data-testid="alert-modal"]'); 53 53 assert(dialog !== null); 54 54 assert(dialog.textContent.includes("Automated account")); 55 55 assert(
+121 -1
tests/unit/specs/templates/whoCanReplyBadge.template.test.js
··· 1 1 import { TestSuite } from "../../testSuite.js"; 2 2 import { assert, assertEquals } from "../../testHelpers.js"; 3 - import { whoCanReplyBadgeTemplate } from "/js/templates/whoCanReplyBadge.template.js"; 3 + import { 4 + whoCanReplyBadgeTemplate, 5 + WhoCanReplyModal, 6 + } from "/js/templates/whoCanReplyBadge.template.js"; 4 7 import { render } from "/js/lib/lit-html.js"; 5 8 6 9 const t = new TestSuite("whoCanReplyBadgeTemplate"); ··· 89 92 const post = { threadgate: { record: { allow: [] } } }; 90 93 const badge = renderBadge(post); 91 94 assertEquals(badge.getAttribute("data-testid"), "who-can-reply-badge"); 95 + }); 96 + }); 97 + 98 + t.describe("WhoCanReplyModal", (it, { beforeEach }) => { 99 + beforeEach(() => { 100 + document.body.innerHTML = ""; 101 + }); 102 + 103 + const everybodyPost = { author: { handle: "alice.test" } }; 104 + const nobodyPost = { 105 + author: { handle: "alice.test" }, 106 + threadgate: { record: { allow: [] } }, 107 + }; 108 + const followersPost = { 109 + author: { handle: "alice.test" }, 110 + threadgate: { 111 + record: { 112 + allow: [{ $type: "app.bsky.feed.threadgate#followerRule" }], 113 + }, 114 + }, 115 + }; 116 + const mentionAndFollowingPost = { 117 + author: { handle: "alice.test" }, 118 + threadgate: { 119 + record: { 120 + allow: [ 121 + { $type: "app.bsky.feed.threadgate#mentionRule" }, 122 + { $type: "app.bsky.feed.threadgate#followingRule" }, 123 + ], 124 + }, 125 + }, 126 + }; 127 + 128 + const findDialog = () => 129 + document.querySelector('[data-testid="who-can-reply-modal"]'); 130 + 131 + it("should create a dialog with the who-can-reply testid", () => { 132 + WhoCanReplyModal.open({ post: everybodyPost }); 133 + const dialog = findDialog(); 134 + assert(dialog !== null); 135 + assert(dialog.hasAttribute("open")); 136 + }); 137 + 138 + it("should render the title", () => { 139 + WhoCanReplyModal.open({ post: everybodyPost }); 140 + const title = document.querySelector('[data-testid="modal-title"]'); 141 + assert(title !== null); 142 + }); 143 + 144 + it("should render everybody message when no threadgate", () => { 145 + WhoCanReplyModal.open({ post: everybodyPost }); 146 + const body = document.querySelector(".who-can-reply-body"); 147 + assert(body.textContent.includes("Everybody can reply to this post.")); 148 + }); 149 + 150 + it("should render nobody message when allow is empty", () => { 151 + WhoCanReplyModal.open({ post: nobodyPost }); 152 + const body = document.querySelector(".who-can-reply-body"); 153 + assert(body.textContent.includes("Replies to this post are disabled.")); 154 + }); 155 + 156 + it("should render followers rule", () => { 157 + WhoCanReplyModal.open({ post: followersPost }); 158 + const body = document.querySelector(".who-can-reply-body"); 159 + assert(body.textContent.includes("Only")); 160 + assert(body.textContent.includes("users following")); 161 + assert(body.textContent.includes("@alice.test")); 162 + assert(body.textContent.includes("can reply.")); 163 + }); 164 + 165 + it("should join multiple rules with 'and'", () => { 166 + WhoCanReplyModal.open({ post: mentionAndFollowingPost }); 167 + const body = document.querySelector(".who-can-reply-body"); 168 + assert(body.textContent.includes("mentioned users")); 169 + assert(body.textContent.includes(", and ")); 170 + assert(body.textContent.includes("users followed by")); 171 + }); 172 + 173 + it("should not show quote message when embedding is enabled", () => { 174 + WhoCanReplyModal.open({ post: everybodyPost }); 175 + const body = document.querySelector(".who-can-reply-body"); 176 + assert(!body.textContent.includes("quote this post")); 177 + }); 178 + 179 + it("should show quote message when embedding is disabled", () => { 180 + WhoCanReplyModal.open({ 181 + post: { ...everybodyPost, viewer: { embeddingDisabled: true } }, 182 + }); 183 + const body = document.querySelector(".who-can-reply-body"); 184 + assert( 185 + body.textContent.includes("No one but the author can quote this post."), 186 + ); 187 + }); 188 + 189 + it("should close and remove on primary button click", () => { 190 + WhoCanReplyModal.open({ post: everybodyPost }); 191 + const button = document.querySelector( 192 + '[data-testid="modal-primary-button"]', 193 + ); 194 + button.click(); 195 + assert(findDialog() === null); 196 + }); 197 + 198 + it("should close and remove on backdrop click", () => { 199 + WhoCanReplyModal.open({ post: everybodyPost }); 200 + const dialog = findDialog(); 201 + dialog.dispatchEvent(new Event("click", { bubbles: true })); 202 + assert(findDialog() === null); 203 + }); 204 + 205 + it("should close and remove on cancel event", () => { 206 + WhoCanReplyModal.open({ post: everybodyPost }); 207 + const dialog = findDialog(); 208 + const cancelEvent = new Event("cancel"); 209 + cancelEvent.preventDefault = () => {}; 210 + dialog.dispatchEvent(cancelEvent); 211 + assert(findDialog() === null); 92 212 }); 93 213 }); 94 214