From 76cb936582e2cc9335366f6a0ac848e72b0f53ca Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 13 Apr 2026 18:38:33 -0500 Subject: [PATCH] feat(components): update ConversationViewer with improved pagination handling and draft persistence --- .../messages/ConversationViewer.vue | 14 +- tests/frontend/ConversationViewer.test.js | 227 ++++++++++++++++++ 2 files changed, 238 insertions(+), 3 deletions(-) diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index 68460ff..1e02eff 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -2306,6 +2306,7 @@ export default { chatItems: [], isLoadingPrevious: false, + loadPreviousInFlight: 0, hasMorePrevious: true, newMessageDeliveryMethod: null, @@ -2746,6 +2747,9 @@ export default { window.addEventListener("resize", this._onWindowResize); }, beforeUnmount() { + if (this.selectedPeer) { + this.saveDraft(this.selectedPeer.destination_hash); + } if (this._onWindowResize) { window.removeEventListener("resize", this._onWindowResize); } @@ -3005,11 +3009,14 @@ export default { this.autoLoadAudioAttachments(); }, async loadPrevious() { - // do nothing if already loading - if (this.isLoadingPrevious) { + // Pagination requests must not overlap. Initial page loads (empty thread) must still run + // if a previous peer's fetch or a scroll load left isLoadingPrevious true; otherwise + // initialLoad clears chatItems and loadPrevious returns without fetching (empty UI). + if (this.isLoadingPrevious && this.oldestMessageId != null) { return; } + this.loadPreviousInFlight += 1; this.isLoadingPrevious = true; try { @@ -3059,7 +3066,8 @@ export default { } catch { this.hasMorePrevious = false; } finally { - this.isLoadingPrevious = false; + this.loadPreviousInFlight = Math.max(0, this.loadPreviousInFlight - 1); + this.isLoadingPrevious = this.loadPreviousInFlight > 0; } }, getParsedItems(chatItem) { diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js index bcd472b..e413acb 100644 --- a/tests/frontend/ConversationViewer.test.js +++ b/tests/frontend/ConversationViewer.test.js @@ -486,4 +486,231 @@ describe("ConversationViewer.vue", () => { ); expect(wrapper.vm.replyingTo).toBeNull(); }); + + describe("conversation history loading", () => { + const deferredConversationGet = () => { + const deferredResolvers = []; + axiosMock.get.mockImplementation((url) => { + if (url.includes("/lxmf-messages/conversation/")) { + return new Promise((resolve) => { + deferredResolvers.push(resolve); + }); + } + if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); + if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); + if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); + if (url.includes("/contacts/check/")) return Promise.resolve({ data: {} }); + return Promise.resolve({ data: {} }); + }); + return deferredResolvers; + }; + + let inboundMsgId = 1000; + const inboundFrom = (hash, content) => ({ + id: inboundMsgId++, + hash: `msg-${hash.slice(0, 4)}-${content}`, + source_hash: hash, + destination_hash: "my-hash", + content, + state: "delivered", + timestamp: 1700000000, + fields: {}, + }); + + it("loads the current peer after switching while a prior fetch was still in flight", async () => { + const deferredResolvers = deferredConversationGet(); + + const peerA = { destination_hash: "a".repeat(32), display_name: "A" }; + const peerB = { destination_hash: "b".repeat(32), display_name: "B" }; + + const wrapper = mountConversationViewer({ + selectedPeer: peerA, + }); + + await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(1)); + + await wrapper.setProps({ selectedPeer: peerB }); + await wrapper.vm.$nextTick(); + + await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(2)); + + deferredResolvers[0]({ data: { lxmf_messages: [] } }); + await wrapper.vm.$nextTick(); + await Promise.resolve(); + expect(wrapper.vm.chatItems).toHaveLength(0); + + deferredResolvers[1]({ + data: { + lxmf_messages: [inboundFrom("b".repeat(32), "hello")], + }, + }); + await vi.waitFor(() => expect(wrapper.vm.chatItems.length).toBe(1)); + expect(wrapper.vm.chatItems[0].lxmf_message.content).toBe("hello"); + }); + + it("applies only the latest peer response when multiple requests resolve out of order", async () => { + const deferredResolvers = deferredConversationGet(); + + const peerA = { destination_hash: "a".repeat(32), display_name: "A" }; + const peerB = { destination_hash: "b".repeat(32), display_name: "B" }; + const peerC = { destination_hash: "c".repeat(32), display_name: "C" }; + + const wrapper = mountConversationViewer({ selectedPeer: peerA }); + await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(1)); + + await wrapper.setProps({ selectedPeer: peerB }); + await wrapper.vm.$nextTick(); + await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(2)); + + await wrapper.setProps({ selectedPeer: peerC }); + await wrapper.vm.$nextTick(); + await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(3)); + + deferredResolvers[0]({ + data: { lxmf_messages: [inboundFrom("a".repeat(32), "stale-a")] }, + }); + deferredResolvers[1]({ + data: { lxmf_messages: [inboundFrom("b".repeat(32), "stale-b")] }, + }); + await wrapper.vm.$nextTick(); + await Promise.resolve(); + expect(wrapper.vm.chatItems).toHaveLength(0); + + deferredResolvers[2]({ + data: { lxmf_messages: [inboundFrom("c".repeat(32), "current")] }, + }); + await vi.waitFor(() => expect(wrapper.vm.chatItems.length).toBe(1)); + expect(wrapper.vm.chatItems[0].lxmf_message.content).toBe("current"); + }); + + it("does not start another page fetch while pagination is already in flight", async () => { + const baseMsg = { + id: 42, + hash: "page1-msg", + source_hash: "test-hash", + destination_hash: "my-hash", + content: "first page", + state: "delivered", + timestamp: 1700000000, + fields: {}, + }; + axiosMock.get.mockImplementation((url) => { + if (url.includes("/lxmf-messages/conversation/")) { + return Promise.resolve({ data: { lxmf_messages: [baseMsg] } }); + } + if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); + if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); + if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); + if (url.includes("/contacts/check/")) return Promise.resolve({ data: {} }); + return Promise.resolve({ data: {} }); + }); + + const wrapper = mountConversationViewer(); + await vi.waitFor(() => expect(wrapper.vm.chatItems.length).toBe(1)); + + const conversationGets = () => + axiosMock.get.mock.calls.filter((c) => String(c[0]).includes("/lxmf-messages/conversation/")); + const countBefore = conversationGets().length; + + wrapper.vm.isLoadingPrevious = true; + await wrapper.vm.loadPrevious(); + + expect(conversationGets().length).toBe(countBefore); + }); + }); + + describe("compose draft persistence", () => { + let draftStore; + + beforeEach(() => { + draftStore = {}; + vi.stubGlobal("localStorage", { + getItem: (key) => (Object.prototype.hasOwnProperty.call(draftStore, key) ? draftStore[key] : null), + setItem: (key, value) => { + draftStore[key] = String(value); + }, + removeItem: (key) => { + delete draftStore[key]; + }, + }); + }); + + it("persists the previous peer draft in localStorage when switching peers", async () => { + const peerA = { destination_hash: "a".repeat(32), display_name: "A" }; + const peerB = { destination_hash: "b".repeat(32), display_name: "B" }; + + const wrapper = mountConversationViewer({ selectedPeer: peerA }); + await wrapper.vm.$nextTick(); + + wrapper.vm.newMessageText = "draft for A"; + await wrapper.setProps({ selectedPeer: peerB }); + await wrapper.vm.$nextTick(); + + const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}"); + expect(drafts["a".repeat(32)]).toBe("draft for A"); + }); + + it("loads the stored draft when opening a peer", async () => { + draftStore["meshchat.drafts"] = JSON.stringify({ + ["b".repeat(32)]: "remembered", + }); + + const wrapper = mountConversationViewer({ + selectedPeer: { destination_hash: "b".repeat(32), display_name: "B" }, + }); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.newMessageText).toBe("remembered"); + }); + + it("round-trips drafts for A then B then back to A", async () => { + const peerA = { destination_hash: "a".repeat(32), display_name: "A" }; + const peerB = { destination_hash: "b".repeat(32), display_name: "B" }; + + const wrapper = mountConversationViewer({ selectedPeer: peerA }); + await wrapper.vm.$nextTick(); + wrapper.vm.newMessageText = "text-a"; + await wrapper.setProps({ selectedPeer: peerB }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.newMessageText).toBe(""); + + wrapper.vm.newMessageText = "text-b"; + await wrapper.setProps({ selectedPeer: peerA }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.newMessageText).toBe("text-a"); + + await wrapper.setProps({ selectedPeer: peerB }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.newMessageText).toBe("text-b"); + }); + + it("removes the draft key when saving an empty compose box for that peer", async () => { + draftStore["meshchat.drafts"] = JSON.stringify({ + ["a".repeat(32)]: "will clear", + }); + + const wrapper = mountConversationViewer({ + selectedPeer: { destination_hash: "a".repeat(32), display_name: "A" }, + }); + await wrapper.vm.$nextTick(); + + wrapper.vm.newMessageText = ""; + wrapper.vm.saveDraft("a".repeat(32)); + + const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}"); + expect(drafts["a".repeat(32)]).toBeUndefined(); + }); + + it("persists the current compose text when the component unmounts", async () => { + const peer = { destination_hash: "a".repeat(32), display_name: "A" }; + const wrapper = mountConversationViewer({ selectedPeer: peer }); + await wrapper.vm.$nextTick(); + + wrapper.vm.newMessageText = "save on leave"; + wrapper.unmount(); + + const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}"); + expect(drafts["a".repeat(32)]).toBe("save on leave"); + }); + }); });