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.

Improve chat detail error state

Grace Kind (Jul 17, 2026, 4:41 PM -0500) d4d146a7 b3e8374e

+111 -28
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.192", 3 + "version": "0.17.193", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+2 -1
src/css/style.css
··· 4544 4544 scrollbar-width: thin; 4545 4545 } 4546 4546 4547 - chat-input .rich-text-input[contenteditable="false"] { 4547 + chat-input .rich-text-input[contenteditable="false"], 4548 + chat-input[disabled] .rich-text-input-placeholder { 4548 4549 opacity: 0.6; 4549 4550 } 4550 4551
+1
src/js/dataLayer/requests.js
··· 208 208 ); 209 209 this.enableStatus(this.loadConvoList, "loadConvoList"); 210 210 this.enableStatus(this.loadConvoRequestList, "loadConvoRequestList"); 211 + this.enableStatus(this.loadConvo, (convoId) => "loadConvo-" + convoId); 211 212 this.enableStatus( 212 213 this.loadConvoMessages, 213 214 (convoId) => "loadConvoMessages-" + convoId,
+34 -25
src/js/views/chatDetail.view.js
··· 38 38 pinScrollPosition, 39 39 } from "/js/utils.js"; 40 40 import { Signal, ReactiveStore } from "/js/signals.js"; 41 + import { ApiError } from "/js/api.js"; 41 42 import { getPermalinkForConvo, linkToProfile } from "/js/navigation.js"; 42 43 import { emojiIconTemplate } from "/js/templates/icons/emojiIcon.template.js"; 43 44 import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; ··· 1293 1294 } 1294 1295 1295 1296 function messagesErrorTemplate({ error }) { 1297 + if (error instanceof ApiError && error.data?.error === "InvalidConvo") { 1298 + return html`<div class="error-state" data-testid="convo-not-found"> 1299 + <h3>Not Found</h3> 1300 + <div>Conversation not found</div> 1301 + <button @click=${() => window.location.reload()}>Try again</button> 1302 + </div>`; 1303 + } 1296 1304 console.error(error); 1297 - return html`<div class="error-state"> 1305 + return html`<div class="error-state" data-testid="messages-error"> 1298 1306 <div>There was an error loading messages.</div> 1299 1307 <button @click=${() => window.location.reload()}>Try again</button> 1300 1308 </div>`; ··· 1329 1337 const messagesData = 1330 1338 dataLayer.derived.$hydratedConvoMessages.get(convoId); 1331 1339 const messages = messagesData?.messages ?? null; 1340 + const convoRequestStatus = dataLayer.requests.statusStore.$statuses.get( 1341 + "loadConvo-" + convoId, 1342 + ); 1332 1343 const messagesRequestStatus = 1333 1344 dataLayer.requests.statusStore.$statuses.get( 1334 1345 "loadConvoMessages-" + convoId, 1335 1346 ); 1347 + const requestError = 1348 + convoRequestStatus.error || messagesRequestStatus.error; 1336 1349 const hasMore = !!messagesData?.cursor; 1337 1350 const isSendingMessage = state.$isSendingMessage.get(); 1338 1351 const isLocked = !!groupDetails && groupDetails.lockStatus !== "unlocked"; ··· 1400 1413 `, 1401 1414 })} 1402 1415 <main class="chat-detail-main"> 1403 - ${(() => { 1404 - if (messagesRequestStatus.error) { 1405 - return messagesErrorTemplate({ 1406 - error: messagesRequestStatus.error, 1407 - }); 1408 - } else if (messages) { 1409 - return messagesTemplate({ 1410 - loadingEnabled: state.$loadingEnabled.get(), 1411 - messages, 1412 - currentUserDid: currentUser?.did, 1413 - convo, 1414 - isGroup: !!groupDetails, 1415 - hasMore, 1416 - canReactNow, 1417 - }); 1418 - } else { 1419 - return html`<div 1420 - class="loading-spinner-container" 1421 - style="padding-top: 16px;" 1422 - > 1423 - <div class="loading-spinner"></div> 1424 - </div>`; 1425 - } 1426 - })()} 1416 + ${requestError 1417 + ? messagesErrorTemplate({ 1418 + error: requestError, 1419 + }) 1420 + : messages 1421 + ? messagesTemplate({ 1422 + loadingEnabled: state.$loadingEnabled.get(), 1423 + messages, 1424 + currentUserDid: currentUser?.did, 1425 + convo, 1426 + isGroup: !!groupDetails, 1427 + hasMore, 1428 + canReactNow, 1429 + }) 1430 + : html`<div 1431 + class="loading-spinner-container" 1432 + style="padding-top: 16px;" 1433 + > 1434 + <div class="loading-spinner"></div> 1435 + </div>`} 1427 1436 <div class="message-input-wrapper"> 1428 1437 ${isLocked 1429 1438 ? html`<div
+11 -1
tests/e2e/mockServer.js
··· 746 746 const url = new URL(route.request().url()); 747 747 const convoId = url.searchParams.get("convoId"); 748 748 const convo = this.convos.find((c) => c.id === convoId); 749 + if (!convo) { 750 + return route.fulfill({ 751 + status: 400, 752 + contentType: "application/json", 753 + body: JSON.stringify({ 754 + error: "InvalidConvo", 755 + message: "Conversation not found", 756 + }), 757 + }); 758 + } 749 759 return route.fulfill({ 750 760 status: 200, 751 761 contentType: "application/json", 752 - body: JSON.stringify({ convo: convo || {} }), 762 + body: JSON.stringify({ convo }), 753 763 }); 754 764 }); 755 765
+16
tests/e2e/specs/views/chatDetail.view.test.js
··· 2392 2392 ).toBeVisible(); 2393 2393 }); 2394 2394 }); 2395 + 2396 + test("shows a not-found error when the conversation doesn't exist", async ({ 2397 + page, 2398 + }) => { 2399 + const mockServer = new MockServer(); 2400 + await mockServer.setup(page); 2401 + 2402 + await login(page); 2403 + await page.goto("/messages/convo-missing"); 2404 + 2405 + const chatDetailView = page.locator("#chat-detail-view"); 2406 + await expect( 2407 + chatDetailView.locator('[data-testid="convo-not-found"]'), 2408 + ).toBeVisible({ timeout: 10000 }); 2409 + await expect(chatDetailView.locator(".message-bubble")).toHaveCount(0); 2410 + }); 2395 2411 });
+46
tests/unit/specs/dataLayer/requests.test.js
··· 1780 1780 }); 1781 1781 }); 1782 1782 1783 + describe("loadConvo", () => { 1784 + const convoId = "convo1"; 1785 + 1786 + it("should store the convo and track status under a namespaced key", async () => { 1787 + const dataStore = new DataStore(); 1788 + const mockApi = { 1789 + getConvo: async () => ({ convo: { id: convoId } }), 1790 + }; 1791 + const requests = makeRequests(mockApi, dataStore); 1792 + 1793 + await requests.loadConvo(convoId); 1794 + 1795 + assert.deepEqual(dataStore.$convos.get(convoId).id, convoId); 1796 + const status = requests.getStatus("loadConvo-" + convoId); 1797 + assert.deepEqual(status.loading, false); 1798 + assert.deepEqual(status.error, null); 1799 + }); 1800 + 1801 + it("should record an ApiError under the namespaced key without rethrowing", async () => { 1802 + const apiError = new ApiError({ 1803 + status: 400, 1804 + statusText: "Bad Request", 1805 + data: { error: "InvalidConvo" }, 1806 + headers: {}, 1807 + url: "/x", 1808 + }); 1809 + const dataStore = new DataStore(); 1810 + const mockApi = { 1811 + getConvo: async () => { 1812 + throw apiError; 1813 + }, 1814 + }; 1815 + const requests = makeRequests(mockApi, dataStore); 1816 + 1817 + await requests.loadConvo(convoId); 1818 + 1819 + const status = requests.getStatus("loadConvo-" + convoId); 1820 + assert.deepEqual(status.loading, false); 1821 + assert( 1822 + status.error === apiError, 1823 + "expected status.error to be the ApiError", 1824 + ); 1825 + assert.deepEqual(dataStore.$convos.get(convoId), null); 1826 + }); 1827 + }); 1828 + 1783 1829 describe("loadConvoMessages", () => { 1784 1830 const convoId = "convo1"; 1785 1831