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
91 changes: 74 additions & 17 deletions apps/fluux/src/components/SearchContextView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ import { ArrowLeft, ExternalLink, Search } from 'lucide-react'
/** Number of messages to load on each side of the target */
const CONTEXT_BATCH_SIZE = 50

/**
* Re-assert budget for the scroll-to-target loop (see the scroll effect below).
* The target row and its neighbours size asynchronously (avatars, media, link
* previews), so a single scroll lands off-position; we recompute across frames
* until the landing point stops moving. ~1.5s at 60fps covers typical settling.
*/
const SCROLL_REASSERT_FRAMES = 90
/** Consecutive stable frames before we consider the target settled. */
const SCROLL_STABLE_FRAMES = 3
/** Landing-point drift (px) treated as "not moved" between frames. */
const SCROLL_DRIFT_PX = 2

export function SearchContextView({ onBack }: { onBack?: () => void }) {
const { t } = useTranslation()
const { query, previewResult, setPreviewResult } = useSearch()
Expand Down Expand Up @@ -170,43 +182,88 @@ export function SearchContextView({ onBack }: { onBack?: () => void }) {
// We handle this here instead of passing targetMessageId to MessageList/useMessageListScroll
// because the scroll hook's live-conversation behaviors (ResizeObserver auto-scroll,
// new message scroll-to-bottom, scroll state persistence) interfere with the static preview.
//
// The target row and the rows above it size asynchronously (avatars, media, link previews),
// so a single scroll computed from the first, unsettled layout lands the target off-position
// — often well above the fold once the content grows. We therefore re-assert the position
// across frames until the landing point stops moving (mirrors the live path's marker/pin
// re-assert loops in useMessageListScroll), bailing the moment the user takes over.
// Keyed on the TARGET (previewResult) + the initial load flag — NOT on messages.length.
// loadMessages sets `messages` and clears `isLoading` in one batched update, so this fires
// exactly once when the target's context is ready. Loading OLDER context on scroll-to-top
// uses a separate `isLoadingOlder` flag, so paginating never re-triggers this — the loop must
// not yank the user back to the target after they deliberately scrolled up.
useEffect(() => {
if (!previewResult || isLoading || messages.length === 0) return
if (!previewResult || isLoading) return

const scroller = scrollRef.current
if (!scroller) return

const escapedId = CSS.escape(previewResult.messageId)

const scrollAndHighlight = () => {
let raf = 0
let framesLeft = SCROLL_REASSERT_FRAMES
let stableFrames = 0
let landed = -1
let lastScrollHeight = -1
let userTookOver = false

// Any manual scroll gesture stops the loop so we never fight the user (e.g. once they
// scroll up to read context). Programmatic scrollTop writes never fire these events;
// covers wheel/trackpad, touch, and scrollbar drag.
const onUserTakeover = () => { userTookOver = true }
scroller.addEventListener('wheel', onUserTakeover, { passive: true })
scroller.addEventListener('touchstart', onUserTakeover, { passive: true })
scroller.addEventListener('mousedown', onUserTakeover, { passive: true })

const step = () => {
raf = 0
if (userTookOver) return
if (framesLeft-- <= 0) return

const el = scroller.querySelector(`[data-message-id="${escapedId}"]`) as HTMLElement | null
if (!el) return false
if (!el) {
// Rows not mounted yet — keep waiting within the frame budget.
raf = requestAnimationFrame(step)
return
}

// Apply persistent highlight (no fade animation — this is a static preview).
el.classList.add('message-highlight-persistent')

// Position the target message ~1/3 down from the viewport top
// Position the target message ~1/3 down from the viewport top.
const scrollerRect = scroller.getBoundingClientRect()
const elementRect = el.getBoundingClientRect()
const elementTop = elementRect.top - scrollerRect.top + scroller.scrollTop
const viewportHeight = scroller.clientHeight
scroller.scrollTop = Math.max(0, elementTop - viewportHeight / 3)

// Apply persistent highlight (no fade animation — this is a static preview)
el.classList.add('message-highlight-persistent')
return true
const desired = Math.max(0, elementTop - scroller.clientHeight / 3)
scroller.scrollTop = desired

// Converged once the landing point and content height both stop changing (rows have
// finished measuring). Use the post-write scrollTop (the browser clamps near the end).
const settledPos = landed >= 0 && Math.abs(scroller.scrollTop - landed) <= SCROLL_DRIFT_PX
const settledHeight = scroller.scrollHeight === lastScrollHeight
if (settledPos && settledHeight) {
if (++stableFrames >= SCROLL_STABLE_FRAMES) return
} else {
stableFrames = 0
}
landed = scroller.scrollTop
lastScrollHeight = scroller.scrollHeight
raf = requestAnimationFrame(step)
}

// Try immediately, then with a short delay for DOM to settle
if (!scrollAndHighlight()) {
requestAnimationFrame(() => {
scrollAndHighlight()
})
}
raf = requestAnimationFrame(step)

return () => {
if (raf) cancelAnimationFrame(raf)
scroller.removeEventListener('wheel', onUserTakeover)
scroller.removeEventListener('touchstart', onUserTakeover)
scroller.removeEventListener('mousedown', onUserTakeover)
// Clean up highlight when switching results
const el = scroller?.querySelector(`[data-message-id="${escapedId}"]`)
el?.classList.remove('message-highlight-persistent')
}
}, [previewResult, isLoading, messages.length])
}, [previewResult, isLoading])

// Load older messages on scroll to top
const handleScrollToTop = useCallback(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@ vi.mock('@/hooks/useNavigateToTarget', () => ({
useNavigateToTarget: () => ({ navigateToConversation: vi.fn(), navigateToRoom: vi.fn() }),
}))

// Expose the avatarUrl the row passes to <Avatar> so the contact case is observable.
// Expose the avatarUrl and shape the row passes to <Avatar> so both the contact
// (circle) and room (square, via RoomAvatar) cases are observable. RoomAvatar is
// left un-mocked so it forwards shape="square" to this mocked Avatar.
vi.mock('../Avatar', () => ({
Avatar: ({ name, avatarUrl }: { name: string; avatarUrl?: string }) => (
<div data-testid="avatar" data-avatar-url={avatarUrl ?? ''}>
Avatar: ({ name, avatarUrl, shape }: { name: string; avatarUrl?: string; shape?: string }) => (
<div data-testid="avatar" data-avatar-url={avatarUrl ?? ''} data-shape={shape ?? 'circle'}>
{name}
</div>
),
Expand Down Expand Up @@ -141,12 +143,15 @@ describe('SearchView avatar reactivity (frozen-derived-value regression guard)',
mockRosterStore.setState({ contacts: new Map() })
})

it('shows the room avatar when it loads AFTER the row first rendered', () => {
it('shows the room avatar (as a rounded-square) when it loads AFTER the row first rendered', () => {
mockSearch = baseSearch([makeResult('1', 'team@conf.example.com', true)])
const { container } = render(<SearchView />)

// Before the avatar resolves: letter-avatar fallback, no <img>.
expect(container.querySelector('img')).toBeNull()
const avatar = () => container.querySelector('[data-testid="avatar"]')
// Rooms render through RoomAvatar → square shape, regardless of image state.
expect(avatar()?.getAttribute('data-shape')).toBe('square')
// Before the avatar resolves: letter-avatar fallback, no image url.
expect(avatar()?.getAttribute('data-avatar-url')).toBe('')

// Room avatar resolves (PEP / vCard fetch) after first render.
act(() => {
Expand All @@ -155,16 +160,17 @@ describe('SearchView avatar reactivity (frozen-derived-value regression guard)',
})
})

const img = container.querySelector('img')
expect(img).not.toBeNull()
expect(img?.getAttribute('src')).toBe('https://example.com/room.png')
expect(avatar()?.getAttribute('data-avatar-url')).toBe('https://example.com/room.png')
expect(avatar()?.getAttribute('data-shape')).toBe('square')
})

it('shows the contact avatar when it loads AFTER the row first rendered', () => {
mockSearch = baseSearch([makeResult('1', 'alice@example.com', false)])
const { container } = render(<SearchView />)

const avatar = () => container.querySelector('[data-testid="avatar"]')
// Contacts render as circles.
expect(avatar()?.getAttribute('data-shape')).toBe('circle')
// Before the vCard resolves: fallback letter avatar, no image url.
expect(avatar()?.getAttribute('data-avatar-url')).toBe('')

Expand Down
23 changes: 8 additions & 15 deletions apps/fluux/src/components/sidebar-components/SearchView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useSearch, chatStore, roomStore, getLocalPart } from '@fluux/sdk'
import { useRoomStore, useRosterStore } from '@fluux/sdk/react'
import type { SearchResult, SearchResultContext, SearchFilterType } from '@fluux/sdk'
import { Avatar } from '../Avatar'
import { RoomAvatar } from '../RoomAvatar'
import { useNavigateToTarget } from '@/hooks/useNavigateToTarget'
import { useListKeyboardNav } from '@/hooks'
import { formatConversationTime } from '@/utils/dateFormat'
Expand Down Expand Up @@ -372,23 +373,15 @@ const SearchResultItem = memo(function SearchResultItem({ result, context, isAct
: 'text-fluux-muted hover:bg-fluux-hover hover:text-fluux-text'
}`}
>
{/* Avatar / icon */}
{/* Avatar / icon — rooms are rounded-squares (with a Hash fallback), people are circles. */}
<div className="flex-shrink-0 mt-0.5">
{result.isRoom ? (
roomAvatar ? (
<img
src={roomAvatar}
alt={result.conversationName}
className="size-6 rounded-full object-cover"
draggable={false}
/>
) : (
<Avatar
identifier={result.conversationId}
name={result.conversationName}
size="xs"
/>
)
<RoomAvatar
identifier={result.conversationId}
name={result.conversationName}
avatarUrl={roomAvatar}
size="xs"
/>
) : (
<Avatar
identifier={result.conversationId}
Expand Down
Loading