From 5baaed468fc07b04d2cefbbb02ffed152be0f587 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 17 Aug 2026 20:40:15 -0500 Subject: [PATCH] Update FAB styling --- package.json | 2 +- src/css/style.css | 28 ++++----- src/js/app.js | 3 + src/js/mainLayout.js | 4 ++ src/js/newChatService.js | 25 ++++++++ src/js/templates/sidebar.template.js | 20 +++++-- src/js/views/chat.view.js | 11 +--- .../specs/flows/newChatFromSidebar.test.js | 53 +++++++++++++++++ tests/e2e/specs/views/chat.view.test.js | 23 ++++++++ tests/unit/specs/mainLayout.test.js | 17 ++++++ .../specs/templates/sidebar.template.test.js | 57 +++++++++++++++++++ 11 files changed, 211 insertions(+), 32 deletions(-) create mode 100644 src/js/newChatService.js create mode 100644 tests/e2e/specs/flows/newChatFromSidebar.test.js diff --git a/package.json b/package.json index a74b3ccd..e8bc1528 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.19.11", + "version": "0.19.12", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index bb1ba6ab..3b0f7254 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1455,7 +1455,7 @@ tab-bar:not([full-width]) { .sidebar-profile-avatar { display: flex; justify-content: flex-start; - margin-bottom: 12px; + margin-bottom: 8px; } @media (min-width: 800px) { @@ -1536,8 +1536,8 @@ tab-bar:not([full-width]) { align-items: center; justify-content: center; gap: 10px; - padding: 3px 14px; - margin: 3px 6px; + padding: 4px 14px; + margin-right: 8px; color: var(--text-color); text-decoration: none; transition: background-color 0.2s; @@ -1625,7 +1625,7 @@ plugin-blob-image .blob-image-fallback { .sidebar-compose-button { display: none; - margin-top: 18px; + margin-top: 12px; margin-left: 10px; width: fit-content; padding: 12px 20px; @@ -7138,13 +7138,19 @@ sidebar-search { transform: scale(0.95); } +@media (min-width: 1100px) { + .fab { + display: none; + } +} + .floating-compose-button .icon { width: 24px; height: 24px; } @media (min-width: 800px) { - .floating-compose-button { + .fab { right: 50px; } @@ -7154,12 +7160,6 @@ sidebar-search { } } -@media (min-width: 1200px) { - .floating-compose-button { - display: none; - } -} - .mention-typeahead-host, .mention-typeahead-host:popover-open { position: fixed; @@ -10026,12 +10026,6 @@ toggle-switch { height: 28px; } -@media (min-width: 800px) { - .new-chat-fab { - display: none; - } -} - @media (max-width: 799px) { .search-dialog { display: flex; diff --git a/src/js/app.js b/src/js/app.js index 807f5724..5f0589bc 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -49,6 +49,7 @@ import { DesktopNotificationService } from "/js/desktopNotificationService.js"; import { startActiveTabMonitor } from "/js/activeTabMonitor.js"; import { PushNotificationService } from "/js/push/pushNotificationService.js"; import { PostComposerService } from "/js/postComposerService.js"; +import { NewChatService } from "/js/newChatService.js"; import { AccountSwitcherService } from "/js/accountSwitcherService.js"; import { ReportService } from "/js/reportService.js"; import { GroupChatLinkService } from "/js/groupChatLinkService.js"; @@ -161,6 +162,7 @@ export async function main() { draftsEnabled: await checkDraftsEnabled(auth), }) : null; + const newChatService = session ? new NewChatService(dataLayer) : null; const accountSwitcherService = session ? new AccountSwitcherService(dataLayer, auth) : null; @@ -251,6 +253,7 @@ export async function main() { desktopNotificationService, pushNotificationService, postComposerService, + newChatService, accountSwitcherService, reportService, groupChatLinkService, diff --git a/src/js/mainLayout.js b/src/js/mainLayout.js index 607c11f0..3edb9184 100644 --- a/src/js/mainLayout.js +++ b/src/js/mainLayout.js @@ -65,6 +65,7 @@ export function mainLayoutTemplate({ onClickActiveNavItem, children, onClickComposeButton, + onClickNewChatButton, pluginService, previewingPlugins = [], onLongPressProfile = null, @@ -91,6 +92,7 @@ export function mainLayoutTemplate({ numChatNotifications, onClickActiveItem: onClickActiveNavItem, onClickComposeButton, + onClickNewChatButton, pluginSidebarItems: pluginService.getSidebarItems(), onLongPressProfile, })} @@ -153,6 +155,7 @@ export class MainLayout extends Layout { notificationService, chatNotificationService, postComposerService, + newChatService, accountSwitcherService, pluginService, groupChatLinkService, @@ -209,6 +212,7 @@ export class MainLayout extends Layout { children: slot, onClickComposeButton: () => postComposerService.composePost({ currentUser }), + onClickNewChatButton: () => newChatService.openNewChatDialog(), pluginService, previewingPlugins, onLongPressProfile, diff --git a/src/js/newChatService.js b/src/js/newChatService.js new file mode 100644 index 00000000..31799abd --- /dev/null +++ b/src/js/newChatService.js @@ -0,0 +1,25 @@ +import "/js/components/new-chat-dialog.js"; + +export class NewChatService { + constructor(dataLayer) { + this.dataLayer = dataLayer; + this.currentDialog = null; + } + + openNewChatDialog() { + if (this.currentDialog !== null) { + console.warn("New chat dialog already open"); + return; + } + this.currentDialog = document.createElement("new-chat-dialog"); + this.currentDialog.dataLayer = this.dataLayer; + this.currentDialog.addEventListener("dialog-closed", () => { + if (this.currentDialog) { + this.currentDialog.remove(); + this.currentDialog = null; + } + }); + document.body.appendChild(this.currentDialog); + this.currentDialog.open(); + } +} diff --git a/src/js/templates/sidebar.template.js b/src/js/templates/sidebar.template.js index 3c35cd03..78a99784 100644 --- a/src/js/templates/sidebar.template.js +++ b/src/js/templates/sidebar.template.js @@ -19,6 +19,7 @@ import { bookmarkIconTemplate } from "/js/templates/icons/bookmarkIcon.template. import { listIconTemplate } from "/js/templates/icons/listIcon.template.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { editIconTemplate } from "/js/templates/icons/editIcon.template.js"; +import { messagePlusIconTemplate } from "/js/templates/icons/messagePlusIcon.template.js"; import { linkToProfileFollowers, linkToProfileFollowing, @@ -198,6 +199,7 @@ export function sidebarTemplate({ numChatNotifications = 0, onClickActiveItem, onClickComposeButton, + onClickNewChatButton, pluginSidebarItems = [], onLongPressProfile = null, }) { @@ -371,15 +373,23 @@ export function sidebarTemplate({ onClickActiveItem, pluginSidebarItems, })} - ${onClickComposeButton + ${activeNavItem === "chat" && onClickNewChatButton ? html`` - : ""} + : onClickComposeButton + ? html`` + : ""} `; diff --git a/src/js/views/chat.view.js b/src/js/views/chat.view.js index 5726c6a3..ef7dc436 100644 --- a/src/js/views/chat.view.js +++ b/src/js/views/chat.view.js @@ -21,14 +21,13 @@ import { inboxIconTemplate } from "/js/templates/icons/inboxIcon.template.js"; import { messagePlusIconTemplate } from "/js/templates/icons/messagePlusIcon.template.js"; import "/js/components/infinite-scroll-container.js"; import "/js/components/container-link.js"; -import "/js/components/new-chat-dialog.js"; import "/js/components/app-icon.js"; export default async function chatView({ root, router, layout, - context: { auth, dataLayer, chatNotificationService }, + context: { auth, dataLayer, chatNotificationService, newChatService }, }) { await auth.requireAuth(); @@ -37,13 +36,7 @@ export default async function chatView({ } function handleNewChatClick() { - const dialog = document.createElement("new-chat-dialog"); - dialog.dataLayer = dataLayer; - dialog.addEventListener("dialog-closed", () => { - dialog.remove(); - }); - document.body.appendChild(dialog); - dialog.open(); + newChatService.openNewChatDialog(); } function newChatButtonTemplate() { diff --git a/tests/e2e/specs/flows/newChatFromSidebar.test.js b/tests/e2e/specs/flows/newChatFromSidebar.test.js new file mode 100644 index 00000000..86c9552b --- /dev/null +++ b/tests/e2e/specs/flows/newChatFromSidebar.test.js @@ -0,0 +1,53 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; +import { createProfile } from "../../../shared/factories.js"; + +test.describe("New chat from sidebar flow", () => { + function createMessageableProfile() { + return createProfile({ + did: "did:plc:alice1", + handle: "alice.bsky.social", + displayName: "Alice", + associated: { chat: { allowIncoming: "all" } }, + }); + } + + test("should start a chat from the sidebar new chat button and land on the conversation", async ({ + page, + }) => { + const mockServer = new MockServer(); + const alice = createMessageableProfile(); + mockServer.addProfile(alice); + mockServer.addTypeaheadProfiles([alice]); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages"); + + const newChatButton = page.locator( + '[data-testid="sidebar-new-chat-button"]', + ); + await expect(newChatButton).toBeVisible({ timeout: 10000 }); + await newChatButton.click(); + + const dialog = page.locator('[data-testid="new-chat-dialog"]'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + await dialog.locator('[data-testid="new-chat-search-input"]').fill("ali"); + + const result = dialog.locator('[data-testid="profile-list-item-button"]'); + await expect(result).toHaveCount(1, { timeout: 10000 }); + await expect(result).toHaveAttribute("data-teststate", "enabled"); + await expect(result).toContainText("Alice"); + await result.click(); + + await expect(dialog).not.toBeVisible(); + await expect(page).toHaveURL(/\/messages\/convo-new-/, { + timeout: 10000, + }); + const chatDetailView = page.locator("#chat-detail-view"); + await expect( + chatDetailView.locator('[data-testid="header-title"]'), + ).toContainText("Alice", { timeout: 10000 }); + }); +}); diff --git a/tests/e2e/specs/views/chat.view.test.js b/tests/e2e/specs/views/chat.view.test.js index 68b7d8ef..00a7a521 100644 --- a/tests/e2e/specs/views/chat.view.test.js +++ b/tests/e2e/specs/views/chat.view.test.js @@ -481,6 +481,29 @@ test.describe("Chat view", () => { await expect(dialog).not.toBeVisible(); }); + test("should open the new chat dialog from the sidebar new chat button", async ({ + page, + }) => { + const mockServer = new MockServer(); + await mockServer.setup(page); + + await login(page); + await page.goto("/messages"); + + const newChatButton = page.locator( + '[data-testid="sidebar-new-chat-button"]', + ); + await expect(newChatButton).toBeVisible({ timeout: 10000 }); + await expect( + page.locator('[data-testid="sidebar-compose-button"]'), + ).toBeHidden(); + await newChatButton.click(); + + await expect(page.locator('[data-testid="new-chat-dialog"]')).toBeVisible({ + timeout: 10000, + }); + }); + test("should show a FAB instead of the header button on mobile viewports", async ({ page, }) => { diff --git a/tests/unit/specs/mainLayout.test.js b/tests/unit/specs/mainLayout.test.js index f9b9b7b8..c5fe381a 100644 --- a/tests/unit/specs/mainLayout.test.js +++ b/tests/unit/specs/mainLayout.test.js @@ -30,6 +30,7 @@ describe("MainLayout", () => { const $numChatNotifications = new Signal.State(0); const sidebarItems = new SignalSet(); const composePost = mock.fn(); + const openNewChatDialog = mock.fn(); const $trends = new Signal.State(null); const loadTrends = mock.fn(async () => {}); const $hydratedPinnedItems = new Signal.State(null); @@ -50,6 +51,7 @@ describe("MainLayout", () => { notificationService: { $numNotifications }, chatNotificationService: { $numNotifications: $numChatNotifications }, postComposerService: { composePost }, + newChatService: { openNewChatDialog }, accountSwitcherService: null, pluginService: makeTestPluginService({ getSidebarItems: () => [...sidebarItems], @@ -71,6 +73,7 @@ describe("MainLayout", () => { $numNotifications, sidebarItems, composePost, + openNewChatDialog, $trends, loadTrends, }; @@ -243,6 +246,20 @@ describe("MainLayout", () => { ); }); + it("opens the new chat dialog from the sidebar new chat button on chat routes", async () => { + const { appRoot, openNewChatDialog, composePost } = harness; + setRoute({ layoutOptions: { activeNavItem: "chat" } }); + await flushRender(); + + assert.deepEqual( + appRoot.querySelector("[data-testid='sidebar-compose-button']"), + null, + ); + appRoot.querySelector("[data-testid='sidebar-new-chat-button']").click(); + assert.deepEqual(openNewChatDialog.mock.callCount(), 1); + assert.deepEqual(composePost.mock.callCount(), 0); + }); + it("lets a layout listener claim active nav clicks via preventDefault", async (t) => { const scrollTo = t.mock.method(window, "scrollTo", () => {}); const { layout, appRoot } = harness; diff --git a/tests/unit/specs/templates/sidebar.template.test.js b/tests/unit/specs/templates/sidebar.template.test.js index d260f45b..5cfd7d84 100644 --- a/tests/unit/specs/templates/sidebar.template.test.js +++ b/tests/unit/specs/templates/sidebar.template.test.js @@ -450,6 +450,63 @@ describe("sidebarTemplate - compose button", () => { container.querySelector("[data-testid='sidebar-compose-button']").click(); assert(clicked); }); + + it("should render new chat button instead of compose button when chat nav item is active", () => { + const result = sidebarTemplate({ + isAuthenticated: true, + currentUser: mockUser, + activeNavItem: "chat", + onClickComposeButton: () => {}, + onClickNewChatButton: () => {}, + }); + const container = document.createElement("div"); + render(result, container); + assert( + container.querySelector("[data-testid='sidebar-new-chat-button']") !== + null, + ); + assert.deepEqual( + container.querySelector("[data-testid='sidebar-compose-button']"), + null, + ); + }); + + it("should render compose button on non-chat nav items even when onClickNewChatButton is provided", () => { + const result = sidebarTemplate({ + isAuthenticated: true, + currentUser: mockUser, + activeNavItem: "home", + onClickComposeButton: () => {}, + onClickNewChatButton: () => {}, + }); + const container = document.createElement("div"); + render(result, container); + assert( + container.querySelector("[data-testid='sidebar-compose-button']") !== + null, + ); + assert.deepEqual( + container.querySelector("[data-testid='sidebar-new-chat-button']"), + null, + ); + }); + + it("should call onClickNewChatButton when new chat button is clicked", () => { + let clicked = false; + const result = sidebarTemplate({ + isAuthenticated: true, + currentUser: mockUser, + activeNavItem: "chat", + onClickComposeButton: () => {}, + onClickNewChatButton: () => { + clicked = true; + }, + }); + const container = document.createElement("div"); + render(result, container); + container.querySelector("[data-testid='sidebar-new-chat-button']").click(); + assert(clicked); + }); }); describe("sidebarTemplate - profile long-press", () => { -- 2.54.0