Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

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

Add welcome modal

Grace Kind (Jul 11, 2026, 2:09 AM -0500) a0dae171 c5f53657

+191 -23
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.135", 3 + "version": "0.17.136", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+49
src/js/modals/welcome.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 WelcomeModal extends Modal { 6 + get className() { 7 + return "bottom-sheet text-modal welcome-modal"; 8 + } 9 + 10 + get attributes() { 11 + return { "data-testid": "welcome-modal" }; 12 + } 13 + 14 + render({ dismiss }) { 15 + return html` 16 + <div class="modal-dialog-content"> 17 + <h2 class="modal-dialog-title" data-testid="modal-title"> 18 + Welcome to Impro! 19 + </h2> 20 + <p class="modal-dialog-message" data-testid="modal-message"> 21 + Impro is a community-built alternative Bluesky client. You can find 22 + more information about the project, including the full source code, at 23 + our 24 + <a href="https://github.com/improsocial/impro/blob/main/README.md" 25 + >GitHub repository</a 26 + >. 27 + </p> 28 + <div class="modal-dialog-buttons"> 29 + <button 30 + class="modal-dialog-button cancel-button" 31 + data-testid="modal-secondary-button" 32 + @click=${() => dismiss()} 33 + > 34 + Explore 35 + </button> 36 + <a 37 + href=${linkToLogin()} 38 + class="modal-dialog-button primary-button" 39 + data-testid="modal-primary-button" 40 + autofocus 41 + @click=${() => dismiss()} 42 + > 43 + Sign in 44 + </a> 45 + </div> 46 + </div> 47 + `; 48 + } 49 + }
+2 -16
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 { alertModal } from "/js/modals/alert.modal.js"; 26 + import { WelcomeModal } from "/js/modals/welcome.modal.js"; 27 27 28 28 function pluginSidebarItemTemplate({ entry }) { 29 29 return html` ··· 42 42 <span class="sidebar-nav-label">${entry.title}</span> 43 43 </button> 44 44 `; 45 - } 46 - 47 - function showAboutModal() { 48 - alertModal( 49 - html`<div> 50 - Impro is a Bluesky client built from the ground-up to be lightweight and 51 - extensible. You can find more information about the project, including the 52 - full source code, at our 53 - <a href="https://github.com/improsocial/impro/blob/main/README.md" 54 - >GitHub repository</a 55 - >. 56 - </div>`, 57 - { title: "About Impro", confirmButtonText: "Got it!" }, 58 - ); 59 45 } 60 46 61 47 function sidebarNavTemplate({ ··· 146 132 class="sidebar-about-link sidebar-text-link" 147 133 data-testid="sidebar-about-link" 148 134 @click=${() => { 149 - showAboutModal(); 135 + WelcomeModal.open(); 150 136 }} 151 137 > 152 138 About
+10
src/js/views/home.view.js
··· 9 9 import { bindToPage, pageEffect } from "/js/router.js"; 10 10 import { showToast } from "/js/toasts.js"; 11 11 import { Signal, ReactiveStore } from "/js/signals.js"; 12 + import { WelcomeModal } from "/js/modals/welcome.modal.js"; 12 13 13 14 class HomeView extends View { 14 15 async render({ ··· 24 25 }, 25 26 }) { 26 27 const CURRENT_FEED_URI_STORAGE_KEY = "home-view-currentFeedUri"; 28 + const WELCOME_MODAL_SEEN_STORAGE_KEY = "welcome-modal-seen"; 27 29 28 30 const storedFeedUri = isAuthenticated 29 31 ? localStorage.getItem(CURRENT_FEED_URI_STORAGE_KEY) ··· 42 44 43 45 if (!state.$currentFeedUri.get()) { 44 46 resetToDefaultFeed(); 47 + } 48 + 49 + if ( 50 + !isAuthenticated && 51 + !sessionStorage.getItem(WELCOME_MODAL_SEEN_STORAGE_KEY) 52 + ) { 53 + sessionStorage.setItem(WELCOME_MODAL_SEEN_STORAGE_KEY, "true"); 54 + WelcomeModal.open(); 45 55 } 46 56 47 57 if (isAuthenticated) {
+9 -1
tests/e2e/mockServer.js
··· 296 296 }); 297 297 } 298 298 299 - async setup(page) { 299 + async setup(page, { welcomeModalSeen = true } = {}) { 300 + // Mark the welcome modal as already seen so it doesn't open over 301 + // logged-out tests; pass welcomeModalSeen: false to test the modal itself. 302 + if (welcomeModalSeen) { 303 + await page.addInitScript(() => { 304 + sessionStorage.setItem("welcome-modal-seen", "true"); 305 + }); 306 + } 307 + 300 308 // Stub gif proxy fetches (gif embeds stream from these CDNs). 301 309 await page.route( 302 310 /https:\/\/(t\.gifs\.bsky\.app|.*\.klipy\.com)\/.*/,
+50
tests/e2e/specs/views/home.view.test.js
··· 469 469 }); 470 470 }); 471 471 472 + test("should show the welcome modal on first visit only", async ({ 473 + page, 474 + }) => { 475 + const mockServer = new MockServer(); 476 + const discoverFeed = createFeedGenerator({ 477 + uri: discoverFeedUri, 478 + displayName: "Discover", 479 + creatorHandle: "bsky.app", 480 + }); 481 + mockServer.addFeedGenerators([discoverFeed]); 482 + await mockServer.setup(page, { welcomeModalSeen: false }); 483 + 484 + await page.goto("/"); 485 + 486 + const modal = page.locator('[data-testid="welcome-modal"]'); 487 + await expect(modal).toBeVisible({ timeout: 10000 }); 488 + await modal.locator('[data-testid="modal-secondary-button"]').click(); 489 + await expect(modal).not.toBeVisible(); 490 + 491 + await page.goto("/"); 492 + const view = page.locator("#home-view"); 493 + await expect(view.locator(".tab-bar-button")).toHaveCount(1, { 494 + timeout: 10000, 495 + }); 496 + await expect(modal).not.toBeVisible(); 497 + }); 498 + 499 + test("should not show the welcome modal when already seen", async ({ 500 + page, 501 + }) => { 502 + const mockServer = new MockServer(); 503 + const discoverFeed = createFeedGenerator({ 504 + uri: discoverFeedUri, 505 + displayName: "Discover", 506 + creatorHandle: "bsky.app", 507 + }); 508 + mockServer.addFeedGenerators([discoverFeed]); 509 + await mockServer.setup(page); 510 + 511 + await page.goto("/"); 512 + 513 + const view = page.locator("#home-view"); 514 + await expect(view.locator(".tab-bar-button")).toHaveCount(1, { 515 + timeout: 10000, 516 + }); 517 + await expect( 518 + page.locator('[data-testid="welcome-modal"]'), 519 + ).not.toBeVisible(); 520 + }); 521 + 472 522 test("should hide logged-in-only UI (compose button, feed switcher)", async ({ 473 523 page, 474 524 }) => {
+50
tests/unit/specs/modals/welcome.modal.test.js
··· 1 + import { describe, it, beforeEach } from "node:test"; 2 + import assert from "node:assert/strict"; 3 + import { WelcomeModal } from "/js/modals/welcome.modal.js"; 4 + 5 + describe("WelcomeModal", () => { 6 + beforeEach(() => { 7 + document.body.innerHTML = ""; 8 + }); 9 + 10 + it("should create a dialog and open it", () => { 11 + WelcomeModal.open(); 12 + const dialog = document.querySelector('[data-testid="welcome-modal"]'); 13 + assert(dialog !== null); 14 + assert(dialog.hasAttribute("open")); 15 + const title = document.querySelector('[data-testid="modal-title"]'); 16 + assert.deepEqual(title.textContent.trim(), "Welcome to Impro!"); 17 + }); 18 + 19 + it("should render a sign in link to the login page", () => { 20 + WelcomeModal.open(); 21 + const link = document.querySelector('[data-testid="modal-primary-button"]'); 22 + assert(link !== null); 23 + assert.deepEqual(link.textContent.trim(), "Sign in"); 24 + assert(link.getAttribute("href").startsWith("/login")); 25 + assert(link.hasAttribute("autofocus")); 26 + }); 27 + 28 + it("should close and remove on sign in link click", () => { 29 + WelcomeModal.open(); 30 + document.querySelector('[data-testid="modal-primary-button"]').click(); 31 + assert(document.querySelector('[data-testid="welcome-modal"]') === null); 32 + }); 33 + 34 + it("should close and remove on explore button click", () => { 35 + WelcomeModal.open(); 36 + const exploreButton = document.querySelector( 37 + '[data-testid="modal-secondary-button"]', 38 + ); 39 + assert.deepEqual(exploreButton.textContent.trim(), "Explore"); 40 + exploreButton.click(); 41 + assert(document.querySelector('[data-testid="welcome-modal"]') === null); 42 + }); 43 + 44 + it("should close and remove on backdrop click", () => { 45 + WelcomeModal.open(); 46 + const dialog = document.querySelector('[data-testid="welcome-modal"]'); 47 + dialog.dispatchEvent(new Event("click", { bubbles: true })); 48 + assert(document.querySelector('[data-testid="welcome-modal"]') === null); 49 + }); 50 + });
-2
tests/unit/specs/templates/footer.template.test.js
··· 211 211 assert(!profileLink.classList.contains("long-press-enabled")); 212 212 }); 213 213 214 - // Press timing and click suppression are enableLongPress behavior, covered 215 - // in utils.test.js; here we only verify the footer wires the handler up. 216 214 it("invokes the handler when a long-press fires on the profile item", () => { 217 215 let fired = 0; 218 216 const profileLink = renderFooter({ onLongPressProfile: () => fired++ });
+20 -3
tests/unit/specs/templates/sidebar.template.test.js
··· 1 - import { describe, it } from "node:test"; 1 + import { describe, it, beforeEach } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 3 import { sidebarTemplate } from "/js/templates/sidebar.template.js"; 4 4 import { render } from "/js/lib/lit-html.js"; ··· 86 86 "[data-testid='sidebar-about-link']", 87 87 ); 88 88 assert(aboutLink !== null); 89 + }); 90 + }); 91 + 92 + describe("sidebarTemplate - welcome modal", () => { 93 + beforeEach(() => { 94 + document.body.innerHTML = ""; 95 + }); 96 + 97 + it("should open the welcome modal when the about link is clicked", () => { 98 + const container = document.createElement("div"); 99 + render( 100 + sidebarTemplate({ isAuthenticated: false, currentUser: null }), 101 + container, 102 + ); 103 + document.body.appendChild(container); 104 + container.querySelector("[data-testid='sidebar-about-link']").click(); 105 + const dialog = document.querySelector('[data-testid="welcome-modal"]'); 106 + assert(dialog !== null); 107 + assert(dialog.hasAttribute("open")); 89 108 }); 90 109 }); 91 110 ··· 366 385 return container.querySelector(".sidebar-profile-avatar"); 367 386 } 368 387 369 - // Press timing and click suppression are enableLongPress behavior, covered 370 - // in utils.test.js; here we only verify the sidebar wires the handler up. 371 388 it("invokes the handler when a long-press fires on the profile avatar", () => { 372 389 let fired = 0; 373 390 const avatar = renderSidebar({ onLongPressProfile: () => fired++ });