Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Chat timeline now opens with a bounded initial message window to reduce
startup scroll depth and perceived room-load latency
- Chat history loading switched from button-based pagination to bidirectional
infinite scroll with guarded top/bottom sentinel triggers
- Initial message viewport now centers around the user's last-read event when
newer messages exist, and otherwise starts at the bottom
- Matrix attachment encryption now uses unpadded base64 for IV and hashes
- Image sending flow now checks room encryption before creating media events
- Space navigation now separates icon-button interaction from text-click area
Expand Down
207 changes: 189 additions & 18 deletions app/components/Chat/MessageList.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import ChatMessageItem from "~/components/Chat/MessageItem.vue";
import { useAppI18n } from "~/composables/useAppI18n";

Expand Down Expand Up @@ -52,14 +52,20 @@ type MediaResolver = (media: {

const props = defineProps<{
messages: MessageItem[];
canLoadOlder?: boolean;
loadingOlder?: boolean;
resolveMediaBlobUrl?: MediaResolver;
currentUserId?: string;
loadingOlder?: boolean;
loadingNewer?: boolean;
centerOnMessageId?: string;
stickToBottom?: boolean;
scrollIntentToken?: number;
preserveViewportOnPrepend?: boolean;
infiniteScrollOffsetPx?: number;
}>();

const emit = defineEmits<{
loadOlder: [];
reachTop: [];
reachBottom: [];
reply: [target: { eventId: string; senderName: string; body: string }];
toggleReaction: [payload: {
messageId: string;
Expand All @@ -73,6 +79,14 @@ const { translateText } = useAppI18n();
const resolvedBlobUrls = ref<Record<string, string>>({});
const loadingMedia = ref<Record<string, boolean>>({});
const lightboxUrl = ref<string | null>(null);
const scrollContainer = ref<HTMLElement | null>(null);
const topSentinel = ref<HTMLElement | null>(null);
const bottomSentinel = ref<HTMLElement | null>(null);
const topObserver = ref<IntersectionObserver | null>(null);
const bottomObserver = ref<IntersectionObserver | null>(null);
const lastTopEmitAt = ref(0);
const lastBottomEmitAt = ref(0);
const observerCooldownMs = 200;

function needsBlobFetch(media: MediaInfo): boolean {
return (
Expand Down Expand Up @@ -130,6 +144,109 @@ function emitReplyTarget(msg: MessageItem) {
});
}

function disconnectObservers() {
topObserver.value?.disconnect();
bottomObserver.value?.disconnect();
topObserver.value = null;
bottomObserver.value = null;
}

function maybeEmitReachTop() {
if (props.loadingOlder) {
return;
}
const now = Date.now();
if (now - lastTopEmitAt.value < observerCooldownMs) {
return;
}
lastTopEmitAt.value = now;
emit("reachTop");
}

function maybeEmitReachBottom() {
if (props.loadingNewer) {
return;
}
const now = Date.now();
if (now - lastBottomEmitAt.value < observerCooldownMs) {
return;
}
lastBottomEmitAt.value = now;
emit("reachBottom");
}

function setupObservers() {
disconnectObservers();
if (
!scrollContainer.value ||
!topSentinel.value ||
!bottomSentinel.value ||
typeof IntersectionObserver === "undefined"
) {
return;
}

const offsetPx = Math.max(0, props.infiniteScrollOffsetPx ?? 180);
const margin = `${offsetPx}px 0px ${offsetPx}px 0px`;
topObserver.value = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
maybeEmitReachTop();
}
}
},
{
root: scrollContainer.value,
rootMargin: margin,
threshold: 0.01,
},
);
bottomObserver.value = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
maybeEmitReachBottom();
}
}
},
{
root: scrollContainer.value,
rootMargin: margin,
threshold: 0.01,
},
);
topObserver.value.observe(topSentinel.value);
bottomObserver.value.observe(bottomSentinel.value);
}

function scrollToBottom() {
const container = scrollContainer.value;
if (!container) {
return;
}
container.scrollTop = container.scrollHeight;
}

function centerMessage(messageId: string) {
const container = scrollContainer.value;
if (!container) {
return;
}
const messageElements = Array.from(
container.querySelectorAll<HTMLElement>("[data-message-id]"),
);
const targetElement = messageElements.find((messageElement) => {
return messageElement.dataset.messageId === messageId;
});
if (!targetElement) {
return;
}
const targetCenterY = targetElement.offsetTop + targetElement.offsetHeight / 2;
const nextScrollTop = targetCenterY - container.clientHeight / 2;
container.scrollTop = Math.max(0, nextScrollTop);
}

watch(
() => props.messages,
(msgs) => {
Expand All @@ -141,23 +258,76 @@ watch(
},
{ immediate: true, deep: false },
);

watch(
() => props.scrollIntentToken,
async () => {
await nextTick();
if (props.centerOnMessageId) {
centerMessage(props.centerOnMessageId);
return;
}
if (props.stickToBottom) {
scrollToBottom();
}
},
{ immediate: true },
);

watch(
() => props.messages,
async (nextMessages, previousMessages) => {
if (!props.preserveViewportOnPrepend) {
return;
}
const container = scrollContainer.value;
if (!container) {
return;
}
if (previousMessages.length === 0 || nextMessages.length <= previousMessages.length) {
return;
}
const previousFirstMessage = previousMessages[0];
const nextFirstMessage = nextMessages[0];
if (!previousFirstMessage || !nextFirstMessage) {
return;
}
if (previousFirstMessage.id === nextFirstMessage.id) {
return;
}
const previousScrollHeight = container.scrollHeight;
await nextTick();
const scrollHeightDelta = container.scrollHeight - previousScrollHeight;
container.scrollTop += scrollHeightDelta;
},
{ deep: false },
);

onMounted(async () => {
await nextTick();
setupObservers();
});

onBeforeUnmount(() => {
disconnectObservers();
});

watch(
[topSentinel, bottomSentinel, scrollContainer],
async () => {
await nextTick();
setupObservers();
},
{ deep: false },
);
</script>

<template>
<div class="flex flex-1 flex-col overflow-y-auto p-4">
<div
v-if="canLoadOlder && messages.length > 0"
class="mb-4 flex justify-center"
>
<UButton
size="sm"
variant="outline"
:loading="loadingOlder"
@click="emit('loadOlder')"
>
{{ translateText("chat.loadOlder") }}
</UButton>
</div>
<div
ref="scrollContainer"
class="flex flex-1 flex-col overflow-y-auto p-4"
>
<div ref="topSentinel" class="h-px w-full" />
<template v-if="messages.length === 0">
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{{ translateText("chat.noMessages") }}
Expand All @@ -176,6 +346,7 @@ watch(
/>
</div>
</template>
<div ref="bottomSentinel" class="h-px w-full" />
</div>

<!-- Lightbox overlay -->
Expand Down
Loading