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

Update convo lists locally

Grace Kind (Jul 19, 2026, 2:35 PM -0500) 7aa1dc28 17ac5461

+318 -34
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.18.17", 3 + "version": "0.18.18", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+38
src/js/dataLayer/dataStore.js
··· 81 81 this.$profiles.set(profile.did, profile); 82 82 } 83 83 } 84 + 85 + // Save the convo and sync convo lists if necessary 86 + setConvo(convo) { 87 + this.$convos.set(convo.id, convo); 88 + const isRequest = convo.status === "request"; 89 + const destinationSignal = isRequest 90 + ? this.$convoRequestList 91 + : this.$convoList; 92 + const destinationList = destinationSignal.get(); 93 + if (destinationList) { 94 + const inList = destinationList.convos.some( 95 + (listConvo) => listConvo.id === convo.id, 96 + ); 97 + destinationSignal.set({ 98 + convos: inList 99 + ? destinationList.convos.map((listConvo) => 100 + listConvo.id === convo.id ? convo : listConvo, 101 + ) 102 + : [convo, ...destinationList.convos], 103 + cursor: destinationList.cursor, 104 + }); 105 + } 106 + // Remove accepted convo from the request list if it's there 107 + if (!isRequest) { 108 + const requestList = this.$convoRequestList.get(); 109 + if ( 110 + requestList && 111 + requestList.convos.some((listConvo) => listConvo.id === convo.id) 112 + ) { 113 + this.$convoRequestList.set({ 114 + convos: requestList.convos.filter( 115 + (listConvo) => listConvo.id !== convo.id, 116 + ), 117 + cursor: requestList.cursor, 118 + }); 119 + } 120 + } 121 + } 84 122 }
+2 -29
src/js/dataLayer/mutations.js
··· 1100 1100 this.dataStore.$joinLinkPreviewsByCode.set(code, updatedPreview); 1101 1101 } 1102 1102 if (res.status === "joined" && res.convo) { 1103 - this.dataStore.$convos.set(res.convo.id, res.convo); 1103 + this.dataStore.setConvo(res.convo); 1104 1104 } 1105 1105 return res; 1106 1106 } ··· 1114 1114 status: "accepted", 1115 1115 }; 1116 1116 1117 - this.dataStore.$convos.set(convo.id, updatedConvo); 1118 - 1119 - // Update the convo in the convo list, adding it if not present 1120 - const convoList = this.dataStore.$convoList.get(); 1121 - if (convoList) { 1122 - const inList = convoList.convos.some( 1123 - (listConvo) => listConvo.id === convo.id, 1124 - ); 1125 - this.dataStore.$convoList.set({ 1126 - convos: inList 1127 - ? convoList.convos.map((listConvo) => 1128 - listConvo.id === convo.id ? updatedConvo : listConvo, 1129 - ) 1130 - : [updatedConvo, ...convoList.convos], 1131 - cursor: convoList.cursor, 1132 - }); 1133 - } 1134 - 1135 - // Remove from request list 1136 - const convoRequestList = this.dataStore.$convoRequestList.get(); 1137 - if (convoRequestList) { 1138 - this.dataStore.$convoRequestList.set({ 1139 - convos: convoRequestList.convos.filter( 1140 - (listConvo) => listConvo.id !== convo.id, 1141 - ), 1142 - cursor: convoRequestList.cursor, 1143 - }); 1144 - } 1117 + this.dataStore.setConvo(updatedConvo); 1145 1118 1146 1119 return updatedConvo; 1147 1120 }
+12 -4
src/js/dataLayer/requests.js
··· 50 50 signal, 51 51 itemsKey, 52 52 page, 53 - { key, requestCursor, overwrite = false }, 53 + { key, requestCursor, overwrite = false, dedupeBy }, 54 54 ) { 55 55 const current = key === undefined ? signal.get() : signal.get(key); 56 56 const currentCursor = current?.cursor ?? ""; ··· 64 64 return false; 65 65 } 66 66 const append = !overwrite && Boolean(currentCursor); 67 - const items = page[itemsKey] ?? []; 67 + let items = page[itemsKey] ?? []; 68 + if (append && dedupeBy) { 69 + const existingIds = new Set( 70 + current[itemsKey].map((item) => item[dedupeBy]), 71 + ); 72 + items = items.filter((item) => !existingIds.has(item[dedupeBy])); 73 + } 68 74 const next = { 69 75 [itemsKey]: append ? [...current[itemsKey], ...items] : items, 70 76 cursor: page.cursor || null, ··· 892 898 writePageToCollection(this.dataStore.$convoList, "convos", res, { 893 899 requestCursor: cursor, 894 900 overwrite: reload, 901 + dedupeBy: "id", // skip convos that were bumped locally 895 902 }); 896 903 } 897 904 ··· 913 920 writePageToCollection(this.dataStore.$convoRequestList, "convos", res, { 914 921 requestCursor: cursor, 915 922 overwrite: reload, 923 + dedupeBy: "id", // skip convos that were bumped locally 916 924 }); 917 925 } 918 926 919 927 async loadConvo(convoId) { 920 928 const labelers = this.requireLabelers(); 921 929 const res = await this.api.getConvo(convoId, { labelers }); 922 - this.dataStore.$convos.set(convoId, res.convo); 930 + this.dataStore.setConvo(res.convo); 923 931 } 924 932 925 933 async loadConvoMembers(convoId, { reload = false } = {}) { ··· 968 976 async loadConvoForProfile(profileDid) { 969 977 const labelers = this.requireLabelers(); 970 978 const res = await this.api.getConvoForMembers([profileDid], { labelers }); 971 - this.dataStore.$convos.set(res.convo.id, res.convo); 979 + this.dataStore.setConvo(res.convo); 972 980 } 973 981 974 982 async loadConvoMessages(convoId, { reload = false, limit = 50 } = {}) {
+63
tests/e2e/specs/flows/chatRequestAccept.test.js
··· 74 74 ); 75 75 }); 76 76 77 + test("should show the accepted conversation in the chat list after navigating back", async ({ 78 + page, 79 + }) => { 80 + const mockServer = new MockServer(); 81 + 82 + const requester = createProfile({ 83 + did: "did:plc:requester1", 84 + handle: "requester.bsky.social", 85 + displayName: "Requester One", 86 + }); 87 + const requestConvo = createConvo({ 88 + id: "convo-req-1", 89 + otherMember: requester, 90 + status: "request", 91 + lastMessage: createMessage({ 92 + id: "msg-req-1", 93 + text: "Hey, can we chat?", 94 + senderDid: requester.did, 95 + }), 96 + }); 97 + mockServer.addConvos([requestConvo]); 98 + await mockServer.setup(page); 99 + 100 + await login(page); 101 + 102 + await page.goto("/messages"); 103 + const chatView = page.locator("#chat-view"); 104 + await expect(chatView.locator(".feed-end-message")).toContainText( 105 + "No conversations yet!", 106 + { timeout: 10000 }, 107 + ); 108 + await expect(chatView.locator(".convo-item")).toHaveCount(0); 109 + 110 + await chatView.locator('[data-testid="inbox-button"]').click(); 111 + const requestsView = page.locator("#chat-requests-view"); 112 + await expect(requestsView.locator(".chat-request-item")).toHaveCount(1, { 113 + timeout: 10000, 114 + }); 115 + 116 + await requestsView.locator(".chat-request-button.accept").click(); 117 + const chatDetailView = page.locator("#chat-detail-view"); 118 + await expect( 119 + chatDetailView.locator('[data-testid="header-title"]'), 120 + ).toContainText("Requester One", { timeout: 10000 }); 121 + 122 + // Back-navigation restores the cached pages from memory without 123 + // refetching, so the accepted conversation must already have moved 124 + // between the in-memory lists 125 + await page.goBack(); 126 + await expect(requestsView.locator(".chat-request-item")).toHaveCount(0, { 127 + timeout: 10000, 128 + }); 129 + 130 + await page.goBack(); 131 + await expect(page).toHaveURL(/\/messages$/); 132 + await expect(chatView.locator(".convo-item")).toHaveCount(1, { 133 + timeout: 10000, 134 + }); 135 + await expect(chatView.locator(".convo-name")).toContainText( 136 + "Requester One", 137 + ); 138 + }); 139 + 77 140 test("should accept a group invite and verify the group moves from requests to main chat list", async ({ 78 141 page, 79 142 }) => {
+41
tests/e2e/specs/flows/newChatFromChatList.test.js
··· 56 56 ).toContainText("Alice", { timeout: 10000 }); 57 57 }); 58 58 59 + test("should show the new conversation in the chat list after navigating back", async ({ 60 + page, 61 + }) => { 62 + const mockServer = new MockServer(); 63 + const alice = createMessageableProfile(); 64 + mockServer.addProfile(alice); 65 + mockServer.addTypeaheadProfiles([alice]); 66 + await mockServer.setup(page); 67 + 68 + await login(page); 69 + await page.goto("/messages"); 70 + const chatView = page.locator("#chat-view"); 71 + await expect(chatView.locator(".feed-end-message")).toContainText( 72 + "No conversations yet!", 73 + { timeout: 10000 }, 74 + ); 75 + await expect(chatView.locator(".convo-item")).toHaveCount(0); 76 + 77 + await chatView.locator('[data-testid="new-chat-button"]').click(); 78 + const dialog = page.locator('[data-testid="new-chat-dialog"]'); 79 + await expect(dialog).toBeVisible({ timeout: 10000 }); 80 + await dialog.locator('[data-testid="new-chat-search-input"]').fill("ali"); 81 + const result = dialog.locator('[data-testid="new-chat-result"]'); 82 + await expect(result).toHaveCount(1, { timeout: 10000 }); 83 + await result.click(); 84 + 85 + const chatDetailView = page.locator("#chat-detail-view"); 86 + await expect( 87 + chatDetailView.locator('[data-testid="header-title"]'), 88 + ).toContainText("Alice", { timeout: 10000 }); 89 + 90 + // Back-navigation restores the chat list from memory without refetching, 91 + // so the new conversation must already be in the in-memory convo list 92 + await page.goBack(); 93 + await expect(page).toHaveURL(/\/messages$/); 94 + await expect(chatView.locator(".convo-item")).toHaveCount(1, { 95 + timeout: 10000, 96 + }); 97 + await expect(chatView.locator(".convo-name")).toContainText("Alice"); 98 + }); 99 + 59 100 test("should show suggested follows when the dialog opens and start a chat from one", async ({ 60 101 page, 61 102 }) => {
+76
tests/unit/specs/dataLayer/dataStore.test.js
··· 31 31 ); 32 32 }); 33 33 }); 34 + 35 + describe("setConvo", () => { 36 + it("should save the convo and prepend it to the loaded convo list", () => { 37 + const dataStore = new DataStore(); 38 + dataStore.$convoList.set({ convos: [{ id: "c1" }], cursor: "page2" }); 39 + const convo = { id: "c2", status: "accepted" }; 40 + 41 + dataStore.setConvo(convo); 42 + 43 + assert.deepEqual(dataStore.$convos.get("c2"), convo); 44 + assert.deepEqual( 45 + dataStore.$convoList.get().convos.map((listConvo) => listConvo.id), 46 + ["c2", "c1"], 47 + ); 48 + assert.deepEqual(dataStore.$convoList.get().cursor, "page2"); 49 + }); 50 + 51 + it("should replace an existing list entry instead of duplicating it", () => { 52 + const dataStore = new DataStore(); 53 + dataStore.$convoList.set({ 54 + convos: [{ id: "c1", unreadCount: 0 }], 55 + cursor: null, 56 + }); 57 + 58 + dataStore.setConvo({ 59 + id: "c1", 60 + status: "accepted", 61 + unreadCount: 2, 62 + }); 63 + 64 + const list = dataStore.$convoList.get(); 65 + assert.deepEqual(list.convos.length, 1); 66 + assert.deepEqual(list.convos[0].unreadCount, 2); 67 + }); 68 + 69 + it("should leave unloaded lists null", () => { 70 + const dataStore = new DataStore(); 71 + 72 + dataStore.setConvo({ id: "c1", status: "accepted" }); 73 + 74 + assert.deepEqual(dataStore.$convos.get("c1").id, "c1"); 75 + assert.deepEqual(dataStore.$convoList.get(), null); 76 + assert.deepEqual(dataStore.$convoRequestList.get(), null); 77 + }); 78 + 79 + it("should route request convos to the request list", () => { 80 + const dataStore = new DataStore(); 81 + dataStore.$convoList.set({ convos: [], cursor: null }); 82 + dataStore.$convoRequestList.set({ convos: [], cursor: null }); 83 + 84 + dataStore.setConvo({ id: "c1", status: "request" }); 85 + 86 + assert.deepEqual(dataStore.$convoList.get().convos, []); 87 + assert.deepEqual( 88 + dataStore.$convoRequestList.get().convos.map((listConvo) => listConvo.id), 89 + ["c1"], 90 + ); 91 + }); 92 + 93 + it("should move an accepted convo out of the request list", () => { 94 + const dataStore = new DataStore(); 95 + dataStore.$convoList.set({ convos: [], cursor: null }); 96 + dataStore.$convoRequestList.set({ 97 + convos: [{ id: "c1", status: "request" }], 98 + cursor: null, 99 + }); 100 + 101 + dataStore.setConvo({ id: "c1", status: "accepted" }); 102 + 103 + assert.deepEqual( 104 + dataStore.$convoList.get().convos.map((listConvo) => listConvo.id), 105 + ["c1"], 106 + ); 107 + assert.deepEqual(dataStore.$convoRequestList.get().convos, []); 108 + }); 109 + });
+85
tests/unit/specs/dataLayer/requests.test.js
··· 1858 1858 assert.deepEqual(dataStore.$convoList.get().cursor, "page3"); 1859 1859 }); 1860 1860 1861 + it("should drop convos already in the list when appending a page", async () => { 1862 + const dataStore = new DataStore(); 1863 + dataStore.$convoList.set({ 1864 + convos: [{ id: "c2", unreadCount: 1 }, { id: "c1" }], 1865 + cursor: "page2", 1866 + }); 1867 + 1868 + const mockApi = { 1869 + listConvos: async () => ({ 1870 + convos: [{ id: "c2", unreadCount: 0 }, { id: "c3" }], 1871 + cursor: "page3", 1872 + }), 1873 + }; 1874 + const requests = makeRequests(mockApi, dataStore); 1875 + 1876 + await requests.loadConvoList(); 1877 + 1878 + const list = dataStore.$convoList.get(); 1879 + assert.deepEqual( 1880 + list.convos.map((listConvo) => listConvo.id), 1881 + ["c2", "c1", "c3"], 1882 + ); 1883 + assert.deepEqual(list.convos[0].unreadCount, 1); 1884 + }); 1885 + 1861 1886 it("should reset cursor and replace on reload", async () => { 1862 1887 const dataStore = new DataStore(); 1863 1888 dataStore.$convoList.set({ convos: [{ id: "c1" }], cursor: "page2" }); ··· 1928 1953 assert.deepEqual(dataStore.$convoRequestList.get().cursor, "page3"); 1929 1954 }); 1930 1955 1956 + it("should drop convos already in the list when appending a page", async () => { 1957 + const dataStore = new DataStore(); 1958 + dataStore.$convoRequestList.set({ 1959 + convos: [{ id: "r2" }, { id: "r1" }], 1960 + cursor: "page2", 1961 + }); 1962 + 1963 + const mockApi = { 1964 + listConvos: async () => ({ 1965 + convos: [{ id: "r2" }, { id: "r3" }], 1966 + cursor: "page3", 1967 + }), 1968 + }; 1969 + const requests = makeRequests(mockApi, dataStore); 1970 + 1971 + await requests.loadConvoRequestList(); 1972 + 1973 + assert.deepEqual( 1974 + dataStore.$convoRequestList.get().convos.map((listConvo) => listConvo.id), 1975 + ["r2", "r1", "r3"], 1976 + ); 1977 + }); 1978 + 1931 1979 it("should reset cursor and replace on reload", async () => { 1932 1980 const dataStore = new DataStore(); 1933 1981 dataStore.$convoRequestList.set({ ··· 1969 2017 const status = requests.getStatus("loadConvo-" + convoId); 1970 2018 assert.deepEqual(status.loading, false); 1971 2019 assert.deepEqual(status.error, null); 2020 + }); 2021 + 2022 + it("should add the convo to the loaded convo list", async () => { 2023 + const dataStore = new DataStore(); 2024 + dataStore.$convoList.set({ convos: [{ id: "other" }], cursor: null }); 2025 + const mockApi = { 2026 + getConvo: async () => ({ convo: { id: convoId, status: "accepted" } }), 2027 + }; 2028 + const requests = makeRequests(mockApi, dataStore); 2029 + 2030 + await requests.loadConvo(convoId); 2031 + 2032 + assert.deepEqual( 2033 + dataStore.$convoList.get().convos.map((listConvo) => listConvo.id), 2034 + [convoId, "other"], 2035 + ); 1972 2036 }); 1973 2037 1974 2038 it("should record an ApiError under the namespaced key without rethrowing", async () => { ··· 1996 2060 "expected status.error to be the ApiError", 1997 2061 ); 1998 2062 assert.deepEqual(dataStore.$convos.get(convoId), null); 2063 + }); 2064 + }); 2065 + 2066 + describe("loadConvoForProfile", () => { 2067 + it("should store the convo and add it to the loaded convo list", async () => { 2068 + const dataStore = new DataStore(); 2069 + dataStore.$convoList.set({ convos: [{ id: "other" }], cursor: null }); 2070 + const mockApi = { 2071 + getConvoForMembers: async () => ({ 2072 + convo: { id: "c-new", status: "accepted" }, 2073 + }), 2074 + }; 2075 + const requests = makeRequests(mockApi, dataStore); 2076 + 2077 + await requests.loadConvoForProfile("did:plc:alice"); 2078 + 2079 + assert.deepEqual(dataStore.$convos.get("c-new").id, "c-new"); 2080 + assert.deepEqual( 2081 + dataStore.$convoList.get().convos.map((listConvo) => listConvo.id), 2082 + ["c-new", "other"], 2083 + ); 1999 2084 }); 2000 2085 }); 2001 2086