mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-06-04 03:41:20 +00:00
feat(messages): implement virtualized message list and image group display; add scrolling utilities for improved performance and user experience
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user