feat(messages): implement virtualized message list and image group display; add scrolling utilities for improved performance and user experience

This commit is contained in:
Ivan
2026-04-16 00:39:11 -05:00
parent ff3f8707af
commit 7c1e99e861
5 changed files with 1641 additions and 1072 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
<template>
<div class="relative w-full shrink-0" :style="{ height: totalSize + 'px' }">
<div
v-for="v in virtualItems"
:key="groups[v.index]?.key ?? v.index"
:ref="measureElement"
:data-index="v.index"
class="absolute left-0 top-0 w-full box-border px-0"
:style="{ transform: `translateY(${v.start}px)` }"
>
<ConversationMessageEntry :entry="groups[v.index]" :cv="cv" />
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { useVirtualizer } from "@tanstack/vue-virtual";
import ConversationMessageEntry from "./ConversationMessageEntry.vue";
import { estimateGroupHeight, findDisplayGroupIndexForMessageHash } from "./messageListVirtual.js";
const props = defineProps({
groups: {
type: Array,
required: true,
},
getScrollElement: {
type: Function,
required: true,
},
cv: {
type: Object,
required: true,
},
});
const virtualizer = useVirtualizer(
computed(() => ({
count: props.groups.length,
getScrollElement: () => props.getScrollElement() ?? null,
estimateSize: (index) => estimateGroupHeight(props.groups[index]),
overscan: 10,
}))
);
const virtualItems = computed(() => virtualizer.value.getVirtualItems());
const totalSize = computed(() => virtualizer.value.getTotalSize());
function measureElement(el) {
if (el) {
virtualizer.value.measureElement(el);
}
}
function scrollToMessageHash(hash) {
const idx = findDisplayGroupIndexForMessageHash(props.groups, hash);
if (idx < 0) {
return;
}
virtualizer.value.scrollToIndex(idx, { align: "center", behavior: "smooth" });
}
function scrollToBottom() {
const n = props.groups.length;
if (n === 0) {
return;
}
virtualizer.value.scrollToIndex(n - 1, { align: "end", behavior: "auto" });
}
function getTotalSize() {
return virtualizer.value.getTotalSize();
}
defineExpose({
scrollToMessageHash,
scrollToBottom,
getTotalSize,
});
</script>
File diff suppressed because it is too large Load Diff
@@ -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;
}
@@ -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;
}