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

Add inbox button

Grace Kind (Jul 7, 2026, 5:28 PM -0500) 2bf92105 2a90f211

+226 -125
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.109", 3 + "version": "0.17.110", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+35 -51
src/css/style.css
··· 519 519 } 520 520 } 521 521 522 + .inbox-button { 523 + position: relative; 524 + display: flex; 525 + align-items: center; 526 + justify-content: center; 527 + cursor: pointer; 528 + color: var(--text-color-muted); 529 + height: 30px; 530 + width: 30px; 531 + border-radius: var(--icon-button-border-radius); 532 + transition: background-color 0.2s; 533 + } 534 + 535 + @media (min-width: 800px) { 536 + .inbox-button:hover { 537 + background-color: var(--back-button-hover-color); 538 + } 539 + } 540 + 541 + .inbox-button .icon { 542 + width: 24px; 543 + height: 24px; 544 + } 545 + 546 + .inbox-button .unread-dot { 547 + position: absolute; 548 + top: 2px; 549 + right: 3px; 550 + width: 8px; 551 + height: 8px; 552 + border-radius: 50%; 553 + background-color: var(--highlight-color); 554 + border: 1px solid var(--background-color); 555 + } 556 + 522 557 .form-group { 523 558 display: flex; 524 559 flex-direction: column; ··· 3364 3399 3365 3400 .chat-main { 3366 3401 padding: 0; 3367 - } 3368 - 3369 - .chat-requests-banner { 3370 - display: flex; 3371 - align-items: center; 3372 - gap: 12px; 3373 - padding: 12px 16px; 3374 - border-bottom: var(--hair) solid var(--post-border-color); 3375 - cursor: pointer; 3376 - transition: background-color 0.2s; 3377 - } 3378 - 3379 - @media (min-width: 800px) { 3380 - .chat-requests-banner:hover { 3381 - background-color: var(--post-hover-color); 3382 - } 3383 - } 3384 - 3385 - .chat-requests-icon { 3386 - flex-shrink: 0; 3387 - width: 32px; 3388 - height: 32px; 3389 - display: flex; 3390 - align-items: center; 3391 - justify-content: center; 3392 - color: var(--text-color); 3393 - } 3394 - 3395 - .chat-requests-content { 3396 - flex: 1; 3397 - } 3398 - 3399 - .chat-requests-title { 3400 - font-weight: 600; 3401 - font-size: 15px; 3402 - } 3403 - 3404 - .chat-requests-arrow { 3405 - flex-shrink: 0; 3406 - color: var(--text-color-muted); 3407 - font-size: 18px; 3408 - } 3409 - 3410 - .chat-requests-banner.unread { 3411 - background-color: var(--highlight-color-tint); 3412 - } 3413 - 3414 - @media (min-width: 800px) { 3415 - .chat-requests-banner.unread:hover { 3416 - background-color: var(--highlight-color-tint-hover); 3417 - } 3418 3402 } 3419 3403 3420 3404 .convo-item {
+1
src/img/icons/custom/inbox.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M19 14h-2.417a5 5 0 0 1-9.166 0H5v4a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-4Zm0-8a1 1 0 0 0-.898-.995L18 5H6a1 1 0 0 0-1 1v6h3.126a1 1 0 0 1 .969.751 3.001 3.001 0 0 0 5.81 0l.056-.16a1 1 0 0 1 .913-.591H19V6Zm2 12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12l.154.004A3 3 0 0 1 21 6v12Z"/></svg>
+9 -1
src/js/chatNotificationService.js
··· 7 7 constructor(api) { 8 8 this.api = api; 9 9 this.$numNotifications = new Signal.State(0); 10 + this.$numUnreadRequestConvos = new Signal.State(0); 10 11 this._optimisticallyReadIds = new Set(); 11 12 this._lastServerTotal = 0; 12 13 } ··· 22 23 async fetchNumNotifications() { 23 24 const { unreadAcceptedConvos = 0, unreadRequestConvos = 0 } = 24 25 await this.api.getChatUnreadCounts(); 26 + this.$numUnreadRequestConvos.set(unreadRequestConvos); 25 27 const serverTotal = unreadAcceptedConvos + unreadRequestConvos; 26 28 // The server total dropped by `delta` since the last poll — that many 27 29 // optimistic reads have been confirmed, so stop subtracting them. ··· 37 39 this.$numNotifications.set(adjusted); 38 40 } 39 41 40 - markNotificationsAsReadForConvo(convoId) { 42 + markNotificationsAsReadForConvo(convoId, { isRequest = false } = {}) { 41 43 if (this._optimisticallyReadIds.has(convoId)) return; 42 44 this._optimisticallyReadIds.add(convoId); 43 45 const count = this.$numNotifications.get(); 44 46 if (count > 0) { 45 47 this.$numNotifications.set(count - 1); 48 + } 49 + if (isRequest) { 50 + const requestCount = this.$numUnreadRequestConvos.get(); 51 + if (requestCount > 0) { 52 + this.$numUnreadRequestConvos.set(requestCount - 1); 53 + } 46 54 } 47 55 } 48 56 }
+15
src/js/templates/icons/inboxIcon.template.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + 3 + // Source: src/img/icons/custom/inbox.svg 4 + export function inboxIconTemplate() { 5 + return html`<div class="icon inbox-icon"> 6 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> 7 + <path 8 + fill="currentColor" 9 + fill-rule="evenodd" 10 + clip-rule="evenodd" 11 + d="M19 14h-2.417a5 5 0 0 1-9.166 0H5v4a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-4Zm0-8a1 1 0 0 0-.898-.995L18 5H6a1 1 0 0 0-1 1v6h3.126a1 1 0 0 1 .969.751 3.001 3.001 0 0 0 5.81 0l.056-.16a1 1 0 0 1 .913-.591H19V6Zm2 12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12l.154.004A3 3 0 0 1 21 6v12Z" 12 + /> 13 + </svg> 14 + </div>`; 15 + }
+24 -25
src/js/views/chat.view.js
··· 14 14 } from "/js/dataHelpers.js"; 15 15 import { avatarTemplate } from "/js/templates/avatar.template.js"; 16 16 import { avatarGroupTemplate } from "/js/templates/avatarGroup.template.js"; 17 + import { inboxIconTemplate } from "/js/templates/icons/inboxIcon.template.js"; 17 18 import "/js/components/infinite-scroll-container.js"; 18 19 import "/js/components/container-link.js"; 19 20 20 21 class ChatView extends View { 21 - async render({ root, router, context: { dataLayer, mainLayout } }) { 22 + async render({ 23 + root, 24 + router, 25 + context: { dataLayer, mainLayout, chatNotificationService }, 26 + }) { 22 27 await auth.requireAuth(); 23 28 24 29 async function handleMenuClick() { ··· 103 108 `; 104 109 } 105 110 106 - function chatRequestsTemplate({ chatRequests }) { 107 - const hasUnreadRequests = chatRequests.some( 108 - (convo) => convo.unreadCount > 0, 109 - ); 111 + function inboxButtonTemplate({ hasUnreadRequests }) { 110 112 return html` 111 113 <container-link 112 - class="chat-requests-banner ${hasUnreadRequests ? "unread" : ""}" 114 + class="inbox-button" 113 115 href="/messages/inbox" 116 + aria-label=${hasUnreadRequests ? "Requests (unread)" : "Requests"} 117 + data-testid="inbox-button" 118 + data-teststate=${hasUnreadRequests ? "unread" : "read"} 114 119 > 115 - <div class="chat-requests-content"> 116 - <div class="chat-requests-title">Chat requests</div> 117 - </div> 118 - <div class="chat-requests-arrow">→</div> 120 + ${inboxIconTemplate()} 121 + ${hasUnreadRequests 122 + ? html`<div class="unread-dot" data-testid="unread-dot"></div>` 123 + : ""} 119 124 </container-link> 120 125 `; 121 126 } ··· 164 169 dataLayer.requests.statusStore.$statuses.get("loadConvoList"); 165 170 const cursor = dataLayer.derived.$convoListCursor.get(); 166 171 const hasMore = !!cursor; 172 + const hasUnreadRequests = 173 + (chatNotificationService?.$numUnreadRequestConvos.get() ?? 0) > 0; 167 174 168 175 render( 169 176 html`<div id="chat-view"> ··· 178 185 showLoadingSpinner: convosRequestStatus.loading && !!convos, 179 186 leftButton: "menu", 180 187 onClickMenuButton: () => handleMenuClick(), 188 + rightItemTemplate: () => 189 + inboxButtonTemplate({ hasUnreadRequests }), 181 190 })} 182 191 <main class="chat-main"> 183 192 ${(() => { ··· 186 195 error: convosRequestStatus.error, 187 196 }); 188 197 } else if (convos && currentUser) { 189 - const chatRequests = convos.filter( 190 - (convo) => convo.status === "request", 191 - ); 192 198 const acceptedConvos = convos.filter( 193 199 (convo) => convo.status === "accepted", 194 200 ); 195 - return html` 196 - <div> 197 - ${chatRequests.length > 0 198 - ? chatRequestsTemplate({ chatRequests }) 199 - : ""} 200 - ${convosTemplate({ 201 - currentUser, 202 - convos: acceptedConvos, 203 - hasMore, 204 - })} 205 - </div> 206 - `; 201 + return convosTemplate({ 202 + currentUser, 203 + convos: acceptedConvos, 204 + hasMore, 205 + }); 207 206 } else { 208 207 return convoSkeletonTemplate(); 209 208 }
+3 -1
src/js/views/chatDetail.view.js
··· 1456 1456 const convo = dataLayer.derived.$convos.get(convoId); 1457 1457 if (!convo?.unreadCount) return; 1458 1458 dataLayer.mutations.markConvoAsRead(convoId); 1459 - chatNotificationService?.markNotificationsAsReadForConvo(convoId); 1459 + chatNotificationService?.markNotificationsAsReadForConvo(convoId, { 1460 + isRequest: convo.status === "request", 1461 + }); 1460 1462 }); 1461 1463 1462 1464 root.addEventListener("click", handleRootClick);
+14 -14
tests/e2e/specs/flows/chatRequestAccept.test.js
··· 35 35 36 36 await login(page); 37 37 38 - // Navigate to chat list first and verify the request banner is shown 38 + // Navigate to chat list first and verify no accepted conversations 39 + // appear in the main list 39 40 await page.goto("/messages"); 40 41 const chatView = page.locator("#chat-view"); 41 - await expect(chatView.locator(".chat-requests-banner")).toBeVisible({ 42 - timeout: 10000, 43 - }); 44 - 45 - // Verify no accepted conversations in the main list 42 + await expect(chatView.locator(".feed-end-message")).toContainText( 43 + "No conversations yet!", 44 + { timeout: 10000 }, 45 + ); 46 46 await expect(chatView.locator(".convo-item")).toHaveCount(0); 47 47 48 - // Navigate to chat requests 49 - await chatView.locator(".chat-requests-banner").click(); 48 + // Navigate to chat requests via the header inbox button 49 + await chatView.locator('[data-testid="inbox-button"]').click(); 50 50 51 51 const requestsView = page.locator("#chat-requests-view"); 52 52 await expect(requestsView.locator(".chat-request-item")).toHaveCount(1, { ··· 105 105 106 106 await login(page); 107 107 108 - // The invite surfaces in the requests banner, not the main list 108 + // The invite surfaces in the requests inbox, not the main list 109 109 await page.goto("/messages"); 110 110 const chatView = page.locator("#chat-view"); 111 - await expect(chatView.locator(".chat-requests-banner")).toBeVisible({ 112 - timeout: 10000, 113 - }); 111 + await expect(chatView.locator(".feed-end-message")).toContainText( 112 + "No conversations yet!", 113 + { timeout: 10000 }, 114 + ); 114 115 await expect(chatView.locator(".convo-item")).toHaveCount(0); 115 116 116 - await chatView.locator(".chat-requests-banner").click(); 117 + await chatView.locator('[data-testid="inbox-button"]').click(); 117 118 118 119 const requestsView = page.locator("#chat-requests-view"); 119 120 const groupItem = requestsView.locator( ··· 139 140 chatView.locator('[data-testid="convo-item-group"]'), 140 141 ).toHaveCount(1, { timeout: 10000 }); 141 142 await expect(chatView.locator(".convo-name")).toContainText("Book Club"); 142 - await expect(chatView.locator(".chat-requests-banner")).toHaveCount(0); 143 143 }); 144 144 });
+4 -6
tests/e2e/specs/flows/leaveConversation.test.js
··· 46 46 47 47 await login(page); 48 48 49 - // Navigate to chat list — should see Alice's convo and the requests banner 49 + // Navigate to chat list — should see only Alice's convo in the main list 50 50 await page.goto("/messages"); 51 51 const chatView = page.locator("#chat-view"); 52 52 await expect(chatView.locator(".convo-item")).toHaveCount(1, { 53 53 timeout: 10000, 54 54 }); 55 55 await expect(chatView.locator(".convo-name")).toContainText("Alice"); 56 - await expect(chatView.locator(".chat-requests-banner")).toBeVisible(); 57 56 58 - // Navigate to chat requests 59 - await chatView.locator(".chat-requests-banner").click(); 57 + // Navigate to chat requests via the header inbox button 58 + await chatView.locator('[data-testid="inbox-button"]').click(); 60 59 61 60 const requestsView = page.locator("#chat-requests-view"); 62 61 await expect(requestsView.locator(".chat-request-item")).toHaveCount(1, { ··· 74 73 // Navigate back to chat list 75 74 await page.goto("/messages"); 76 75 77 - // Verify only Alice's accepted convo remains — no requests banner 76 + // Verify only Alice's accepted convo remains 78 77 await expect(chatView.locator(".convo-item")).toHaveCount(1, { 79 78 timeout: 10000, 80 79 }); 81 80 await expect(chatView.locator(".convo-name")).toContainText("Alice"); 82 - await expect(chatView.locator(".chat-requests-banner")).toHaveCount(0); 83 81 }); 84 82 });
+40 -22
tests/e2e/specs/views/chat.view.test.js
··· 134 134 ).toContainText("Alice", { timeout: 10000 }); 135 135 }); 136 136 137 - test("should navigate to chat requests when clicking the banner", async ({ 137 + test("should navigate to chat requests when clicking the header inbox button", async ({ 138 + page, 139 + }) => { 140 + const mockServer = new MockServer(); 141 + await mockServer.setup(page); 142 + 143 + await login(page); 144 + await page.goto("/messages"); 145 + 146 + const chatView = page.locator("#chat-view"); 147 + const inboxButton = chatView.locator('[data-testid="inbox-button"]'); 148 + await expect(inboxButton).toBeVisible({ timeout: 10000 }); 149 + await expect(inboxButton).toHaveAttribute("data-teststate", "read"); 150 + await expect(inboxButton.locator('[data-testid="unread-dot"]')).toHaveCount( 151 + 0, 152 + ); 153 + 154 + await inboxButton.click(); 155 + 156 + const requestsView = page.locator("#chat-requests-view"); 157 + await expect( 158 + requestsView.locator('[data-testid="header-title"]'), 159 + ).toContainText("Chat requests", { timeout: 10000 }); 160 + }); 161 + 162 + test("should show an unread dot on the inbox button when unread requests exist", async ({ 138 163 page, 139 164 }) => { 140 165 const mockServer = new MockServer(); ··· 147 172 id: "convo-req-1", 148 173 otherMember: requester, 149 174 status: "request", 175 + unreadCount: 1, 150 176 lastMessage: createMessage({ 151 177 id: "msg-req-1", 152 178 text: "Hi there!", ··· 160 186 await page.goto("/messages"); 161 187 162 188 const chatView = page.locator("#chat-view"); 163 - await expect(chatView.locator(".chat-requests-banner")).toBeVisible({ 189 + const inboxButton = chatView.locator('[data-testid="inbox-button"]'); 190 + await expect(inboxButton).toBeVisible({ timeout: 10000 }); 191 + await expect(inboxButton).toHaveAttribute("data-teststate", "unread", { 164 192 timeout: 10000, 165 193 }); 166 - 167 - await chatView.locator(".chat-requests-banner").click(); 168 - 169 - const requestsView = page.locator("#chat-requests-view"); 170 194 await expect( 171 - requestsView.locator('[data-testid="header-title"]'), 172 - ).toContainText("Chat requests", { timeout: 10000 }); 173 - await expect(requestsView.locator(".chat-request-item")).toHaveCount(1); 195 + inboxButton.locator('[data-testid="unread-dot"]'), 196 + ).toBeVisible(); 174 197 }); 175 198 176 - test("should show chat requests banner when requests exist", async ({ 199 + test("should keep request conversations out of the main list", async ({ 177 200 page, 178 201 }) => { 179 202 const mockServer = new MockServer(); ··· 199 222 await page.goto("/messages"); 200 223 201 224 const chatView = page.locator("#chat-view"); 202 - await expect(chatView.locator(".chat-requests-banner")).toBeVisible({ 203 - timeout: 10000, 204 - }); 205 - await expect(chatView.locator(".chat-requests-title")).toContainText( 206 - "Chat requests", 225 + await expect(chatView.locator(".feed-end-message")).toContainText( 226 + "No conversations yet!", 227 + { timeout: 10000 }, 207 228 ); 229 + await expect(chatView.locator(".convo-item")).toHaveCount(0); 208 230 }); 209 231 210 232 test.describe("Group conversations", () => { ··· 384 406 ); 385 407 }); 386 408 387 - test("should show the chat requests banner for group invites", async ({ 388 - page, 389 - }) => { 409 + test("should keep group invites out of the main list", async ({ page }) => { 390 410 const mockServer = new MockServer(); 391 411 const groupInvite = createGroupConvo({ 392 412 id: "group-req-1", ··· 405 425 await login(page); 406 426 await page.goto("/messages"); 407 427 408 - const chatView = page.locator("#chat-view"); 409 - await expect(chatView.locator(".chat-requests-banner")).toBeVisible({ 410 - timeout: 10000, 411 - }); 412 428 // The invite is still a request, so the main list stays empty 429 + const chatView = page.locator("#chat-view"); 413 430 await expect(chatView.locator(".feed-end-message")).toContainText( 414 431 "No conversations yet!", 432 + { timeout: 10000 }, 415 433 ); 416 434 }); 417 435 });
+3 -4
tests/e2e/specs/views/chatRequests.view.test.js
··· 398 398 await page.goto("/messages"); 399 399 400 400 const chatView = page.locator("#chat-view"); 401 - await expect(chatView.locator(".chat-requests-banner")).toBeVisible({ 402 - timeout: 10000, 403 - }); 404 - await chatView.locator(".chat-requests-banner").click(); 401 + const inboxButton = chatView.locator('[data-testid="inbox-button"]'); 402 + await expect(inboxButton).toBeVisible({ timeout: 10000 }); 403 + await inboxButton.click(); 405 404 406 405 const requestsView = page.locator("#chat-requests-view"); 407 406 await expect(
+77
tests/unit/specs/chatNotificationService.test.js
··· 21 21 const api = createMockApi(); 22 22 const service = new ChatNotificationService(api); 23 23 assertEquals(service.$numNotifications.get(), 0); 24 + assertEquals(service.$numUnreadRequestConvos.get(), 0); 24 25 }); 25 26 }); 26 27 ··· 64 65 await service.fetchNumNotifications(); 65 66 66 67 assertEquals(service.$numNotifications.get(), 0); 68 + }); 69 + 70 + it("should publish the unread request count from the server", async () => { 71 + const api = createMockApi({ 72 + unreadAcceptedConvos: 2, 73 + unreadRequestConvos: 3, 74 + }); 75 + const service = new ChatNotificationService(api); 76 + 77 + await service.fetchNumNotifications(); 78 + 79 + assertEquals(service.$numUnreadRequestConvos.get(), 3); 80 + }); 81 + 82 + it("should overwrite the unread request count on each poll", async () => { 83 + let unreadRequestConvos = 3; 84 + const api = { 85 + getChatUnreadCounts: async () => ({ 86 + unreadAcceptedConvos: 0, 87 + unreadRequestConvos, 88 + }), 89 + }; 90 + const service = new ChatNotificationService(api); 91 + 92 + await service.fetchNumNotifications(); 93 + assertEquals(service.$numUnreadRequestConvos.get(), 3); 94 + 95 + unreadRequestConvos = 1; 96 + await service.fetchNumNotifications(); 97 + assertEquals(service.$numUnreadRequestConvos.get(), 1); 67 98 }); 68 99 }); 69 100 ··· 158 189 // Reading "a" again should decrement, since the dedup set was cleared. 159 190 service.markNotificationsAsReadForConvo("a"); 160 191 assertEquals(service.$numNotifications.get(), 2); 192 + }); 193 + 194 + it("should decrement the request count for request convos", async () => { 195 + const api = createMockApi({ unreadRequestConvos: 3 }); 196 + const service = new ChatNotificationService(api); 197 + 198 + await service.fetchNumNotifications(); 199 + assertEquals(service.$numUnreadRequestConvos.get(), 3); 200 + 201 + service.markNotificationsAsReadForConvo("a", { isRequest: true }); 202 + assertEquals(service.$numUnreadRequestConvos.get(), 2); 203 + assertEquals(service.$numNotifications.get(), 2); 204 + }); 205 + 206 + it("should not decrement the request count for accepted convos", async () => { 207 + const api = createMockApi({ 208 + unreadAcceptedConvos: 2, 209 + unreadRequestConvos: 3, 210 + }); 211 + const service = new ChatNotificationService(api); 212 + 213 + await service.fetchNumNotifications(); 214 + 215 + service.markNotificationsAsReadForConvo("a"); 216 + assertEquals(service.$numUnreadRequestConvos.get(), 3); 217 + }); 218 + 219 + it("should not decrement the request count twice for the same convo id", async () => { 220 + const api = createMockApi({ unreadRequestConvos: 3 }); 221 + const service = new ChatNotificationService(api); 222 + 223 + await service.fetchNumNotifications(); 224 + 225 + service.markNotificationsAsReadForConvo("a", { isRequest: true }); 226 + service.markNotificationsAsReadForConvo("a", { isRequest: true }); 227 + assertEquals(service.$numUnreadRequestConvos.get(), 2); 228 + }); 229 + 230 + it("should not decrement the request count below zero", async () => { 231 + const api = createMockApi(); 232 + const service = new ChatNotificationService(api); 233 + 234 + await service.fetchNumNotifications(); 235 + 236 + service.markNotificationsAsReadForConvo("a", { isRequest: true }); 237 + assertEquals(service.$numUnreadRequestConvos.get(), 0); 161 238 }); 162 239 }); 163 240