From 7c1e99e86184ec3590fcefa654d746d83c5962e2 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 16 Apr 2026 00:39:11 -0500 Subject: [PATCH] feat(messages): implement virtualized message list and image group display; add scrolling utilities for improved performance and user experience --- .../messages/ConversationMessageEntry.vue | 1079 +++++++++++++ .../ConversationMessageListVirtual.vue | 80 + .../messages/ConversationViewer.vue | 1410 ++++------------- .../components/messages/conversationScroll.js | 90 ++ .../components/messages/messageListVirtual.js | 54 + 5 files changed, 1641 insertions(+), 1072 deletions(-) create mode 100644 meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue create mode 100644 meshchatx/src/frontend/components/messages/ConversationMessageListVirtual.vue create mode 100644 meshchatx/src/frontend/components/messages/conversationScroll.js create mode 100644 meshchatx/src/frontend/components/messages/messageListVirtual.js diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue new file mode 100644 index 0000000..426a7f6 --- /dev/null +++ b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue @@ -0,0 +1,1079 @@ + + + diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageListVirtual.vue b/meshchatx/src/frontend/components/messages/ConversationMessageListVirtual.vue new file mode 100644 index 0000000..80cfb5f --- /dev/null +++ b/meshchatx/src/frontend/components/messages/ConversationMessageListVirtual.vue @@ -0,0 +1,80 @@ + + + diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index 681e07c..934a107 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -404,1036 +404,29 @@
-
- - - +
+ +
+ +
+ +
+
+ + +
+
+
+ {{ $t("messages.react") }} + +
+ +
+
+
+
{{ emo }} +
import Utils from "../../js/Utils"; +import { isNearBottom, scrollContainerToBottom, shouldLoadPreviousMessages } from "./conversationScroll.js"; +import ConversationMessageEntry from "./ConversationMessageEntry.vue"; +import ConversationMessageListVirtual from "./ConversationMessageListVirtual.vue"; +import { displayGroupsOldestFirst, MIN_VIRTUAL_DISPLAY_GROUPS } from "./messageListVirtual.js"; import DialogUtils from "../../js/DialogUtils"; import MicrophoneRecorder from "../../js/MicrophoneRecorder"; import WebSocketConnection from "../../js/WebSocketConnection"; @@ -2548,6 +1637,8 @@ export default { AudioWaveformPlayer, PaperMessageModal, LxmfUserIcon, + ConversationMessageEntry, + ConversationMessageListVirtual, }, props: { config: { @@ -2651,6 +1742,9 @@ export default { justOpened: false, }, columbaReactionEmojis: COLUMBA_REACTION_EMOJIS, + reactionPickerChatItem: null, + reactionPickerPos: null, + _reactionDrag: null, userStickers: [], isStickerPickerOpen: false, emojiStickerTab: "emoji", @@ -2665,6 +1759,10 @@ export default { windowWidth: typeof window !== "undefined" ? window.innerWidth : 1024, peerHeaderCompact: false, peerHeaderResizeObserver: null, + _scrollBottomGen: 0, + _prevScrollWantedLoadPrevious: false, + _initialLoadActive: false, + messagesViewportReady: true, }; }, computed: { @@ -2678,6 +1776,20 @@ export default { void GlobalState.config?.theme; return GlobalState.config?.theme === "dark" ? "dark" : "light"; }, + reactionPickerStyle() { + if (this.reactionPickerPos) { + return { + left: this.reactionPickerPos.x + "px", + top: this.reactionPickerPos.y + "px", + position: "fixed", + }; + } + return { + bottom: "0.5rem", + left: "50%", + transform: "translateX(-50%)", + }; + }, usesThemeOutboundBubbleColor() { const c = GlobalState?.config?.message_outbound_bubble_color; if (c == null || String(c).trim() === "") { @@ -2888,21 +2000,18 @@ export default { }) .reverse(); }, - selectedPeerChatItemsReversed() { - // ensure a copy of the array is returned in reverse order - return this.selectedPeerChatItems.map((message) => message).reverse(); - }, selectedPeerChatDisplayGroups() { - const reversed = this.selectedPeerChatItemsReversed; + const items = this.selectedPeerChatItems; + const n = items.length; const groups = []; - let i = 0; - while (i < reversed.length) { - const item = reversed[i]; + let r = 0; + while (r < n) { + const item = items[n - 1 - r]; if (this.canMergeImageIntoImageStrip(item)) { const run = [item]; - let j = i + 1; - while (j < reversed.length && run.length < 12) { - const next = reversed[j]; + let j = r + 1; + while (j < n && run.length < 12) { + const next = items[n - 1 - j]; if (next.is_outbound !== item.is_outbound) break; if (!this.canMergeImageIntoImageStrip(next)) break; run.push(next); @@ -2914,7 +2023,7 @@ export default { items: run, key: run.map((x) => x.lxmf_message.hash).join("-"), }); - i = j; + r = j; continue; } } @@ -2923,10 +2032,23 @@ export default { chatItem: item, key: item.lxmf_message.hash, }); - i++; + r++; } return groups; }, + selectedPeerChatDisplayGroupsOldestFirst() { + return displayGroupsOldestFirst(this.selectedPeerChatDisplayGroups); + }, + conversationViewerSelf() { + return this; + }, + useVirtualMessageList() { + const n = this.selectedPeerChatDisplayGroups.length; + if (n < MIN_VIRTUAL_DISPLAY_GROUPS) { + return false; + } + return GlobalState?.config?.message_list_virtualization !== false; + }, oldestMessageId() { if (this.selectedPeerChatItems.length > 0) { return this.selectedPeerChatItems[0].lxmf_message.id; @@ -2972,12 +2094,15 @@ export default { this.saveDraft(oldPeer.destination_hash); } this.teardownPeerHeaderResizeObserver(); + this._scrollBottomGen += 1; + this.messagesViewportReady = false; if (!newPeer) { this.peerHeaderCompact = false; } this.checkIfSelectedPeerBlocked(); this.strangerBannerDismissed = false; this.checkIfStrangerPeer(); + this._prevScrollWantedLoadPrevious = false; this.initialLoad(); if (newPeer) { this.loadDraft(newPeer.destination_hash); @@ -2986,6 +2111,13 @@ export default { }, immediate: true, }, + useVirtualMessageList: { + handler(value) { + if (!value && !this._initialLoadActive) { + this.messagesViewportReady = true; + } + }, + }, newMessageText() { this.$nextTick(() => { this.adjustTextareaHeight(); @@ -3050,6 +2182,7 @@ export default { window.addEventListener("resize", this._onWindowResize); }, beforeUnmount() { + this._scrollBottomGen += 1; this.teardownPeerHeaderResizeObserver(); if (this.selectedPeer) { this.saveDraft(this.selectedPeer.destination_hash); @@ -3314,27 +2447,30 @@ export default { close() { this.$emit("close"); }, + getMessagesScrollElement() { + return this.$refs.messagesScroll ?? null; + }, onMessagesScroll(event) { - // check if messages is scrolled to bottom const element = event.target; - const isAtBottom = element.scrollTop === element.scrollHeight - element.offsetHeight; + this.autoScrollOnNewMessage = isNearBottom(element); - // we want to auto scroll if user is at bottom of messages list - this.autoScrollOnNewMessage = isAtBottom; - - // load previous when scrolling near top of page - if (element.scrollTop <= 500) { + const wantLoad = shouldLoadPreviousMessages(element); + if (wantLoad && !this._prevScrollWantedLoadPrevious) { this.loadPrevious(); } + this._prevScrollWantedLoadPrevious = wantLoad; }, async initialLoad() { - // reset + this._initialLoadActive = true; + this.messagesViewportReady = false; this.chatItems = []; this.hasMorePrevious = true; this.selectedPeerPath = null; this.selectedPeerLxmfStampInfo = null; this.selectedPeerSignalMetrics = null; if (!this.selectedPeer) { + this._initialLoadActive = false; + this.messagesViewportReady = true; return; } @@ -3343,16 +2479,13 @@ export default { this.getPeerSignalMetrics(); this.warmPathToPeer(); - // mark as read this.markConversationAsRead(this.selectedPeer); - // load 1 page of previous messages await this.loadPrevious(); - // scroll to bottom + this._initialLoadActive = false; this.scrollMessagesToBottom(); - // auto load audio this.autoLoadAudioAttachments(); }, async loadPrevious() { @@ -3399,11 +2532,29 @@ export default { }); } - // add messages to start of existing messages + const scrollEl = this.$refs.messagesScroll; + const needsAnchor = scrollEl && this.chatItems.length > 0 && this.oldestMessageId != null; + + if (needsAnchor) { + scrollEl.style.overflowY = "hidden"; + } + + const prevScrollHeight = scrollEl ? scrollEl.scrollHeight : 0; + const prevScrollTop = scrollEl ? scrollEl.scrollTop : 0; + for (const chatItem of chatItems) { this.chatItems.unshift(chatItem); } + if (needsAnchor) { + this.$nextTick(() => { + const newScrollHeight = scrollEl.scrollHeight; + const delta = newScrollHeight - prevScrollHeight; + scrollEl.scrollTop = prevScrollTop + delta; + scrollEl.style.overflowY = ""; + }); + } + if (chatItems.length < pageSize) { this.hasMorePrevious = false; } @@ -3813,17 +2964,50 @@ export default { ); }, scrollMessagesToBottom: function () { - // next tick waits for the ui to have the new elements added + this._scrollBottomGen += 1; + const gen = this._scrollBottomGen; + const stale = () => gen !== this._scrollBottomGen; this.$nextTick(() => { - // set timeout with zero millis seems to fix issue where it doesn't scroll all the way to the bottom... - setTimeout(() => { - const container = document.getElementById("messages"); - if (container) { - container.scrollTop = container.scrollHeight; + if (stale()) return; + this.$nextTick(() => { + if (stale()) return; + const container = this.$refs.messagesScroll; + if (!container) { + this.messagesViewportReady = true; + return; } - }, 0); + const pump = () => { + if (this.useVirtualMessageList && this.$refs.messageListVirtual) { + this.$refs.messageListVirtual.scrollToBottom(); + } + scrollContainerToBottom(container); + }; + pump(); + if (!this.useVirtualMessageList) { + requestAnimationFrame(() => { + if (stale()) return; + pump(); + this.messagesViewportReady = true; + }); + } else { + let passes = 0; + const settle = () => { + if (stale()) return; + pump(); + passes++; + if (isNearBottom(container) || passes >= 6) { + this.messagesViewportReady = true; + } else { + requestAnimationFrame(settle); + } + }; + requestAnimationFrame(settle); + } + }); }); }, + + isLxmfMessageInUi: function (hash) { return this.chatItems.findIndex((chatItem) => chatItem.lxmf_message?.hash === hash) !== -1; }, @@ -3948,19 +3132,35 @@ export default { }, scrollToMessage(hash) { const index = this.chatItems.findIndex((item) => item.lxmf_message?.hash === hash); - if (index !== -1) { - const el = document.getElementById(`message-${hash}`); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - // briefly highlight - el.classList.add("ring-2", "ring-blue-500", "ring-offset-2"); - setTimeout(() => { - el.classList.remove("ring-2", "ring-blue-500", "ring-offset-2"); - }, 2000); - } - } else { + if (index === -1) { DialogUtils.alert(this.$t("messages.message_not_found_in_cache")); + return; } + const el = document.getElementById(`message-${hash}`); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.add("ring-2", "ring-blue-500", "ring-offset-2"); + setTimeout(() => { + el.classList.remove("ring-2", "ring-blue-500", "ring-offset-2"); + }, 2000); + return; + } + if (this.useVirtualMessageList && this.$refs.messageListVirtual) { + this.$refs.messageListVirtual.scrollToMessageHash(hash); + this.$nextTick(() => { + requestAnimationFrame(() => { + const el2 = document.getElementById(`message-${hash}`); + if (el2) { + el2.classList.add("ring-2", "ring-blue-500", "ring-offset-2"); + setTimeout(() => { + el2.classList.remove("ring-2", "ring-blue-500", "ring-offset-2"); + }, 2000); + } + }); + }); + return; + } + DialogUtils.alert(this.$t("messages.message_not_found_in_cache")); }, getRepliedMessage(hash) { const item = this.chatItems.find((i) => i.lxmf_message?.hash === hash); @@ -4012,6 +3212,55 @@ export default { reactionHash: lxmfMessage.hash, }); }, + openReactionPicker(chatItem) { + this.reactionPickerPos = null; + this.reactionPickerChatItem = chatItem; + }, + closeReactionPicker() { + this.reactionPickerChatItem = null; + this.reactionPickerPos = null; + this._reactionDrag = null; + }, + onReactionPickerDragStart(e) { + const evt = e.touches ? e.touches[0] : e; + const panel = this.$refs.reactionPickerPanel; + if (!panel) return; + const rect = panel.getBoundingClientRect(); + this._reactionDrag = { + startX: evt.clientX, + startY: evt.clientY, + originX: rect.left, + originY: rect.top, + }; + const onMove = (me) => { + const mv = me.touches ? me.touches[0] : me; + const dx = mv.clientX - this._reactionDrag.startX; + const dy = mv.clientY - this._reactionDrag.startY; + this.reactionPickerPos = { + x: this._reactionDrag.originX + dx, + y: this._reactionDrag.originY + dy, + }; + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.removeEventListener("touchmove", onMove); + document.removeEventListener("touchend", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.addEventListener("touchmove", onMove, { passive: false }); + document.addEventListener("touchend", onUp); + }, + onReactionPickerEmojiClick(event) { + const emoji = event.detail?.unicode; + if (!emoji || !this.reactionPickerChatItem) { + return; + } + const chatItem = this.reactionPickerChatItem; + this.reactionPickerChatItem = null; + this.sendReactionEmojiFromMenu(chatItem, emoji); + }, async sendReactionEmojiFromMenu(chatItem, emoji) { this.messageContextMenu.show = false; const hash = chatItem.lxmf_message?.hash; @@ -5994,4 +5243,21 @@ export default { .markdown-content--outbound-solid :deep(a) { color: #dbeafe; } + +.scroll-fab-enter-active, +.scroll-fab-leave-active { + transition: opacity 0.15s ease, transform 0.15s ease; +} +.scroll-fab-enter-from, +.scroll-fab-leave-to { + opacity: 0; +} + +.reaction-emoji-picker { + width: 100%; + height: min(280px, 45vh); + min-height: 200px; + --border-radius: 0; + --border-size: 0; +} diff --git a/meshchatx/src/frontend/components/messages/conversationScroll.js b/meshchatx/src/frontend/components/messages/conversationScroll.js new file mode 100644 index 0000000..0716d39 --- /dev/null +++ b/meshchatx/src/frontend/components/messages/conversationScroll.js @@ -0,0 +1,90 @@ +export const SCROLL_BOTTOM_EPS_PX = 8; + +export const LOAD_PREVIOUS_SCROLL_EDGE_PX = 500; + +/** + * The message list uses `flex-col-reverse` on the inner wrapper; scrollTop is 0 at the visual bottom + * (newest messages) and increases toward older history. + * @param {Element} container + * @returns {boolean} + */ +export function isScrollColumnReverse(container) { + const inner = container?.firstElementChild; + if (!inner) { + return false; + } + try { + return getComputedStyle(inner).flexDirection === "column-reverse"; + } catch { + return false; + } +} + +/** + * Maximum valid scrollTop for a scroll container. + * @param {Element} container + * @returns {number} + */ +export function maxScrollTop(container) { + if (!container) { + return 0; + } + return Math.max(0, container.scrollHeight - container.clientHeight); +} + +/** + * Whether the viewport is within thresholdPx of the visual bottom (newest messages). + * @param {Element} container + * @param {number} [thresholdPx] + * @returns {boolean} + */ +export function isNearBottom(container, thresholdPx = SCROLL_BOTTOM_EPS_PX) { + if (!container) { + return true; + } + if (isScrollColumnReverse(container)) { + return container.scrollTop <= thresholdPx; + } + const max = maxScrollTop(container); + return max - container.scrollTop <= thresholdPx; +} + +/** + * Sets scroll position to the visual bottom (newest messages). + * @param {Element} container + */ +export function scrollContainerToBottom(container) { + if (!container) { + return; + } + if (isScrollColumnReverse(container)) { + container.scrollTop = 0; + } else { + container.scrollTop = maxScrollTop(container); + } +} + +/** + * Whether the user has scrolled into the region where older messages should be loaded. + * @param {Element} container + * @returns {boolean} + */ +export function shouldLoadPreviousMessages(container) { + if (!container) { + return false; + } + if (isScrollColumnReverse(container)) { + const max = maxScrollTop(container); + if (max <= 0) { + return false; + } + const st = container.scrollTop; + if (max - st > LOAD_PREVIOUS_SCROLL_EDGE_PX) { + return false; + } + // Short threads: `max - st` is small even at the visual bottom (newest), because `max` itself + // is small. Require leaving the bottom band so we do not auto-load in a loop while pinned there. + return st > SCROLL_BOTTOM_EPS_PX; + } + return container.scrollTop <= LOAD_PREVIOUS_SCROLL_EDGE_PX; +} diff --git a/meshchatx/src/frontend/components/messages/messageListVirtual.js b/meshchatx/src/frontend/components/messages/messageListVirtual.js new file mode 100644 index 0000000..bbeca6d --- /dev/null +++ b/meshchatx/src/frontend/components/messages/messageListVirtual.js @@ -0,0 +1,54 @@ +export const MIN_VIRTUAL_DISPLAY_GROUPS = 48; + +/** + * Display groups from {@link selectedPeerChatDisplayGroups} are newest-first. + * Virtual + normal document flow use oldest at the top (index 0). + * @param {unknown[]} displayGroups + * @returns {unknown[]} + */ +export function displayGroupsOldestFirst(displayGroups) { + if (!displayGroups?.length) { + return []; + } + return displayGroups.slice().reverse(); +} + +/** + * Initial row height guess before measureElement runs (variable-height rows). + * @param {unknown} entry + * @returns {number} + */ +export function estimateGroupHeight(entry) { + if (!entry || typeof entry !== "object") { + return 96; + } + if (entry.type === "imageGroup") { + return 340; + } + return 120; +} + +/** + * @param {unknown[]} groupsOldestFirst + * @param {string} hash + * @returns {number} + */ +export function findDisplayGroupIndexForMessageHash(groupsOldestFirst, hash) { + if (!groupsOldestFirst?.length || !hash) { + return -1; + } + for (let i = 0; i < groupsOldestFirst.length; i++) { + const g = groupsOldestFirst[i]; + if (!g || typeof g !== "object") { + continue; + } + if (g.type === "imageGroup" && Array.isArray(g.items)) { + if (g.items.some((it) => it?.lxmf_message?.hash === hash)) { + return i; + } + } else if (g.type === "single" && g.chatItem?.lxmf_message?.hash === hash) { + return i; + } + } + return -1; +}