diff --git a/ui/src/routes/conversations/[id]/+page.svelte b/ui/src/routes/conversations/[id]/+page.svelte index 8bee3b71..896ab93b 100644 --- a/ui/src/routes/conversations/[id]/+page.svelte +++ b/ui/src/routes/conversations/[id]/+page.svelte @@ -29,6 +29,7 @@ import SvgIcon from "$lib/SvgIcon.svelte"; import DialogConfirm from "$lib/DialogConfirm.svelte"; import ConversationHeader from "./ConversationHeader.svelte"; + import Avatar from "$lib/Avatar.svelte"; const conversationStore = getContext<{ getStore: () => ConversationStore }>( "conversationStore", @@ -69,6 +70,8 @@ let deleteMessageActionHashB64: undefined | ActionHashB64 = undefined; let isDeletingMessage = false; + let showAvatarDialog = false; + let isFirstConfigLoad = true; let isFirstProfilesLoad = true; let isFirstLoadMessages = true; @@ -232,6 +235,33 @@
+ +
+ goto('/conversations')} + icon="caretLeft" + moreClasses="!h-[16px] !w-[16px] text-base" + moreClassesButton="p-4" + /> + +
+

{$conversationTitle}

@@ -275,7 +305,7 @@ { deleteMessageActionHashB64 = e.detail; showDeleteDialog = true; @@ -304,3 +334,33 @@ >

{$t("common.delete_message_dialog_message")}

+ + +{#if showAvatarDialog} +
showAvatarDialog = false} + on:keydown={(e) => e.key === 'Escape' && (showAvatarDialog = false)} + role="button" + tabindex="0" + > +
+ {#if $conversation.dnaProperties.privacy === Privacy.Private} +
+ {#each $joined.list + .filter(([agentPubKeyB64]) => agentPubKeyB64 !== myPubKeyB64) + .slice(0, 2) as [agentPubKeyB64] (agentPubKeyB64)} + + {/each} +
+ {:else if $conversation.config?.image} + Conversation + {/if} +

{$conversationTitle}

+
+
+{/if} diff --git a/ui/src/routes/conversations/[id]/ConversationMessages.svelte b/ui/src/routes/conversations/[id]/ConversationMessages.svelte index 7eea31d0..263b5856 100644 --- a/ui/src/routes/conversations/[id]/ConversationMessages.svelte +++ b/ui/src/routes/conversations/[id]/ConversationMessages.svelte @@ -3,9 +3,7 @@ import type { ActionHashB64 } from "@holochain/client"; import type { MessageExtended, CellIdB64 } from "$lib/types"; import BaseMessage from "./Message.svelte"; - import ConversationHeader from "./ConversationHeader.svelte"; - import { createVirtualizer } from "@tanstack/svelte-virtual"; - import { afterUpdate, beforeUpdate, createEventDispatcher, onMount, tick } from "svelte"; + import { createEventDispatcher, onMount } from "svelte"; const dispatch = createEventDispatcher<{ scrollAtTop: null; @@ -18,160 +16,110 @@ let selected: ActionHashB64 | undefined; let containerEl: HTMLDivElement | null = null; - let initialScrollReady = false; - - $: chronologicalMessages = messages; - - const MESSAGE_FIXED_HEIGHT = 40; - const UPDATE_TRIGGER_VIEW_OFFSET = 250; - const BUFFER_COUNT = 10; - - let virtualizer = createVirtualizer({ - count: chronologicalMessages?.length, - getScrollElement: () => containerEl, - estimateSize: () => MESSAGE_FIXED_HEIGHT, - overscan: BUFFER_COUNT, - useAnimationFrameWithResizeObserver: true, - }); - // Update virtualizer count when messages change - $: if ($virtualizer) $virtualizer.setOptions({ count: chronologicalMessages?.length }); - - function measure(node: HTMLElement) { - const index = Number(node.dataset.index); - if (isNaN(index)) return; - - if (!$virtualizer) return; - const observer = new ResizeObserver(() => { - requestAnimationFrame(() => { - $virtualizer.measureElement(node); - }); - }); - - observer.observe(node); - - return { - destroy() { - observer.disconnect(); - }, - }; - } - - let isAtBottom = true; - let wasAtBottom = true; - let isAtTop = false; - let wasAtTop = false; - - onMount(async () => { - if (chronologicalMessages.length > 0 && containerEl) { - isAtBottom = true; - wasAtBottom = true; - isAtTop = false; - wasAtTop = false; - - await scrollToBottom(); + let scrollReady = false; + + // =========================================== + // CONFIGURATION + // =========================================== + const SCROLL_TRIGGER_THRESHOLD = 1200; // Pixels from oldest messages to trigger loading (increased for fast scroll) + const DEBOUNCE_MS = 150; // Reduced debounce for faster response + + // =========================================== + // MESSAGE ORDER + // Parent passes: messages={[...$messages.list].reverse()} which is [oldest...newest] + // We reverse again to get [newest...oldest] so index 0 = newest + // With flex-col-reverse, index 0 appears at the bottom (correct!) + // =========================================== + $: reversedMessages = [...messages].reverse(); + + // =========================================== + // SCROLL HANDLING + // In flex-col-reverse: + // - scrollTop = 0 means we're at the BOTTOM (newest messages visible) + // - scrollTop increases as we scroll UP (toward older messages) + // - maxScrollTop = scrollHeight - clientHeight = fully scrolled to TOP (oldest) + // =========================================== + let lastTriggerTime = 0; + let scrollEndTimer: ReturnType | null = null; + + function handleScroll() { + if (!containerEl || !scrollReady) return; + + const { scrollTop, scrollHeight, clientHeight } = containerEl; + const maxScrollTop = scrollHeight - clientHeight; + + // In flex-col-reverse: + // - scrollTop = 0 means at BOTTOM (newest messages) + // - scrollTop goes NEGATIVE as you scroll UP toward older messages + // - At oldest messages, scrollTop approaches -maxScrollTop + // So distanceFromOldest = maxScrollTop + scrollTop (will be small when near oldest) + const distanceFromOldest = maxScrollTop + scrollTop; + const isNearOldest = distanceFromOldest <= SCROLL_TRIGGER_THRESHOLD; + + // Load older messages when scrolling near the oldest messages (top of visual list) + const now = Date.now(); + if (isNearOldest && !loadingTop && now - lastTriggerTime > DEBOUNCE_MS) { + lastTriggerTime = now; + dispatch("scrollAtTop"); } - }); - - // to resolve glitch when (fetching older msgs from hc + loading msgs to store from localDB) - let previousScrollHeight = 0; - let previousItemCount = 0; - let shouldMaintainScroll = false; - let isFirstFetch = true; - - - beforeUpdate(() => { - // only capture the scrollHeight if a maintenance request is active. - if (shouldMaintainScroll && containerEl) { - previousScrollHeight = containerEl.scrollHeight; - } - }); - - // applying manual scroll maintainance - afterUpdate(() => { - if (shouldMaintainScroll && containerEl) { - shouldMaintainScroll = false; - - const newScrollHeight = containerEl.scrollHeight; - - const heightDifference = - newScrollHeight - previousScrollHeight + (!isFirstFetch ? 20 * 40 : 0); - - if (isFirstFetch) isFirstFetch = false; - - containerEl.scrollTop = heightDifference; - } - }); - - // logic for triggering fetch event, newly_added_items-scroll-down logic - $: { - const currentItemCount = chronologicalMessages.length; - - if (containerEl && initialScrollReady) { - const { scrollTop, scrollHeight, clientHeight } = containerEl; - const scrollBottom = scrollHeight - scrollTop - clientHeight; - - const isAtBottom = scrollBottom < 5; - const isAtTop = scrollTop <= UPDATE_TRIGGER_VIEW_OFFSET; - if (wasAtBottom && currentItemCount > previousItemCount) { - scrollToBottom("smooth"); - } - - if (isAtTop && !wasAtTop && !loadingTop) { - shouldMaintainScroll = true; + // Also check when scrolling stops (for fast scroll detection) + if (scrollEndTimer) clearTimeout(scrollEndTimer); + scrollEndTimer = setTimeout(() => { + checkAndTriggerLoad(); + }, 100); + } + // Check if we should load more messages (called after scroll stops or messages change) + function checkAndTriggerLoad() { + if (!containerEl || !scrollReady || loadingTop) return; + + const { scrollTop, scrollHeight, clientHeight } = containerEl; + const maxScrollTop = scrollHeight - clientHeight; + const distanceFromOldest = maxScrollTop + scrollTop; + + if (distanceFromOldest <= SCROLL_TRIGGER_THRESHOLD) { + const now = Date.now(); + if (now - lastTriggerTime > DEBOUNCE_MS) { + lastTriggerTime = now; dispatch("scrollAtTop"); } - - wasAtBottom = isAtBottom; - wasAtTop = isAtTop; } - - previousItemCount = currentItemCount; } - async function scrollToBottom(behavior?: "auto" | "smooth") { - await waitForListLoad(); - - const lastIndex = chronologicalMessages.length - 1; - if (lastIndex < 0) return; - - let attempts = 0; - while (attempts < 5) { - // making sure the initial scroll lands completely at bottom edge of the container - $virtualizer.scrollToIndex(lastIndex + 999, { - align: "start", - behavior, - }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - attempts++; - } - - requestAnimationFrame(() => { - initialScrollReady = true; - }); + // Re-check after messages change - if still near top and not loading, trigger again + $: if (scrollReady && containerEl && messages.length > 0 && !loadingTop) { + // Use tick to ensure DOM is updated before measuring + setTimeout(() => { + checkAndTriggerLoad(); + }, 100); } - async function waitForListLoad() { - await tick(); - - return new Promise((resolve) => { - const check = () => { - const lastIndex = chronologicalMessages?.length - 1; + // =========================================== + // MOUNT - No scrolling needed! Newest messages are already at bottom + // =========================================== + onMount(() => { + + if (containerEl) { + containerEl.addEventListener("scroll", handleScroll, { passive: true }); + } - if ($virtualizer.getVirtualItems().length > 0 && lastIndex >= 0) { - resolve({}); - } else { - requestAnimationFrame(check); // keep checking on next frame - } - }; + // Small delay before enabling scroll detection + setTimeout(() => { + scrollReady = true; + console.log("[MOUNT] Scroll detection enabled"); + }, 100); - check(); - }); - } + return () => { + if (containerEl) { + containerEl.removeEventListener("scroll", handleScroll); + } + }; + }); + // =========================================== + // UTILITY FUNCTIONS + // =========================================== function handleClick(e: MouseEvent, actionHashB64: ActionHashB64) { e.stopPropagation(); selected = selected === actionHashB64 ? undefined : isMobile() ? undefined : actionHashB64; @@ -187,90 +135,102 @@ } } - function shouldShowDaySeparator(currentIndex: number) { - if (currentIndex === 0) return true; + // Messages are in reverse chronological order: index 0 = newest, index n-1 = oldest + // Check the NEXT item in array (which is the PREVIOUS message chronologically) + function shouldShowDaySeparator(index: number) { + // index 0 = newest message, index n-1 = oldest message + const currentMsg = reversedMessages?.[index]?.[1]; + const nextMsg = reversedMessages?.[index + 1]?.[1]; // This is chronologically BEFORE current - const currentMsg = chronologicalMessages?.[currentIndex]?.[1]; - const prevMsg = chronologicalMessages?.[currentIndex - 1]?.[1]; + if (!currentMsg) return false; - if (!currentMsg || !prevMsg) return true; + // If there's no older message, this is the oldest - show its day + if (!nextMsg) return true; - return !isSameDay(new Date(currentMsg.timestamp / 1000), new Date(prevMsg.timestamp / 1000)); + // Show separator if current message is on a DIFFERENT day than the older message + return !isSameDay(new Date(currentMsg.timestamp / 1000), new Date(nextMsg.timestamp / 1000)); } - function shouldShowAuthor(currentIndex: number) { - if (currentIndex === 0) return true; - - const currentMsg = chronologicalMessages[currentIndex][1]; - const prevMsg = chronologicalMessages[currentIndex - 1][1]; + function shouldShowAuthor(index: number) { + const currentMsg = reversedMessages[index]?.[1]; + const nextMsg = reversedMessages[index + 1]?.[1]; // Chronologically BEFORE current - if (!currentMsg || !prevMsg) return true; + if (!currentMsg) return true; + if (!nextMsg) return true; // Oldest message always shows author + // Show author if different from the previous message (chronologically) return ( - currentMsg.authorAgentPubKeyB64 !== prevMsg.authorAgentPubKeyB64 || + currentMsg.authorAgentPubKeyB64 !== nextMsg.authorAgentPubKeyB64 || !isWithinFiveMinutes( new Date(currentMsg.timestamp / 1000), - new Date(prevMsg.timestamp / 1000), + new Date(nextMsg.timestamp / 1000), ) ); } + + function getDayHeaderDate(index: number) { + const msg = reversedMessages?.[index]?.[1]; + if (!msg) return null; + return new Date(msg.timestamp / 1000); + }
- -
- -
- - -
- - {#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)} - {@const currentIndex = virtualRow.index} - {@const [actionHashB64, messageExtended] = chronologicalMessages[currentIndex]} -
-
- - {#if shouldShowDaySeparator(currentIndex)} + + + +
+ {#each reversedMessages as [actionHashB64, messageExtended], index (actionHashB64)} + {@const isOldestMessage = index === reversedMessages.length - 1} + +
+ + {#if shouldShowDaySeparator(index)} + {@const dayDate = getDayHeaderDate(index)} + {#if dayDate}
- {new Date(messageExtended.timestamp / 1000).toLocaleDateString("en-US", { + {dayDate.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", })}
{/if} - - -
- handlePress(actionHashB64)} - on:click={(e) => handleClick(e, actionHashB64)} - on:clickoutside={handleClickOutside} - on:delete - /> -
- - - {#if currentIndex === chronologicalMessages?.length - 1} -
- {/if} + {/if} + + +
+ handlePress(actionHashB64)} + on:click={(e) => handleClick(e, actionHashB64)} + on:clickoutside={handleClickOutside} + on:delete + />
+ + + {#if isOldestMessage} +
+ {/if}
{/each}
+ + +
\ No newline at end of file