Skip to content
Open
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
177 changes: 62 additions & 115 deletions shared/chat/conversation/list-area/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ const usePagination = (p: {
return {onEndReached, onStartReached}
}

const centerTolerancePx = 8
// A scroller within this many pixels of its end counts as at the end.
const endTolerancePx = 2

Expand Down Expand Up @@ -212,6 +211,60 @@ const HighlightableRow = React.memo(({ordinal}: {ordinal: T.Chat.Ordinal}) => {
})
HighlightableRow.displayName = 'HighlightableRow'

// Sending the list to the centered ordinal: a search hit, a reply-quote jump, a pinned message.
//
// Centring on the raw ordinal change is unreliable: navigating to a hit reloads the thread centred
// on it, so the target is briefly absent from messageOrdinals when the ordinal changes. Wait for it
// to arrive, then scroll once per target and no more. Re-issuing when the target's index moves looks
// reasonable - a prepend does shift it - but scrolling is what triggers that prepend, so it would
// re-centre the list out from under someone reading around the hit. The list holds the target in
// place while rows measure (patches/@legendapp+list scrollTargetSettle), and
// maintainVisibleContentPosition holds it across prepends.
//
// Reset per dataset rather than per conversation: re-centring on the ordinal already stored still
// clears and reloads the thread, so the list has to be sent to it again.
const useScrollToCentered = (p: {
centeredOrdinal: T.Chat.Ordinal | undefined
datasetKey: string
listRef: React.RefObject<LegendListRef | null>
messageOrdinals: ReadonlyArray<T.Chat.Ordinal>
ready: boolean
}) => {
const {centeredOrdinal, datasetKey, listRef, messageOrdinals, ready} = p
const lastScrolledRef = React.useRef<T.Chat.Ordinal | undefined>(undefined)
React.useLayoutEffect(() => {
lastScrolledRef.current = undefined
}, [datasetKey])

// Unconditional on purpose, and safe to be: every imperative scroll on the ref calls
// supersedeInitialScroll synchronously inside runScrollWithPromise (see
// node_modules/@legendapp/list/react.mjs), which cancels the list's own initialScrollIndex
// bootstrap before the scroll is even queued. So this call cannot race the bootstrap - it
// supersedes it by construction and always wins. A library bump that broke that guarantee is the
// one thing that would make this regress.
//
// A guard that stood this call down while the bootstrap looked like it owned the target was tried
// and cannot be made safe: on the permalink path the thread mounts with no centred target, so the
// list is built with initialScrollAtEnd and the bootstrap it re-arms when the centred dataset
// lands leaves it at the end, target never shown. That path is indistinguishable from a warm
// in-thread jump by anything visible here (both arrive as "dataset with a resolvable
// initialScrollIndex"), so this call has to be the one authority that always fires.
React.useEffect(() => {
if (!ready || centeredOrdinal === undefined) {
lastScrolledRef.current = undefined
return
}
if (lastScrolledRef.current === centeredOrdinal) return
if (sortedIndexOf(messageOrdinals as unknown as number[], centeredOrdinal as unknown as number) < 0) {
return
}
lastScrolledRef.current = centeredOrdinal
void listRef.current?.scrollToItem({animated: false, item: centeredOrdinal, viewPosition: 0.5})
// datasetKey is a dependency without being read: the layout effect above clears the latch on a
// new dataset, and this effect has to re-run afterwards to scroll again for the same ordinal.
}, [centeredOrdinal, datasetKey, listRef, messageOrdinals, ready])
}

const DesktopThreadWrapper = function DesktopThreadWrapper() {
const desktopStyles = useDesktopStyles()
const editingOrdinal = InputState.useConversationInput(s => s.editing)
Expand All @@ -221,7 +274,9 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() {
const {clearVersion, containsLatestMessage, messageOrdinals, loaded} = data

// Centered loads (search hit, reply-quote jump, pinned message) clear the thread before
// refetching, so the list sees a non-empty -> empty -> non-empty transition.
// refetching, so the list sees a non-empty -> empty -> non-empty transition it cannot recover
// from on its own. dataKey tells it the data is a new dataset, which is what makes it reset
// rather than wait for a container layout that never comes.
const datasetKey = `${conversationIDKey}:${clearVersion}`

const listRef = React.useRef<LegendListRef | null>(null)
Expand Down Expand Up @@ -319,111 +374,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() {
[onScroll]
)

// Scroll to centered ordinal when it changes (search / thread navigation).
// Use a "last scrolled to" ref rather than a "did it change" ref so we still
// scroll when loaded becomes true after centeredOrdinal was already set.
// Reset per dataset, not per conversation: re-centering on the ordinal we are already parked
// on still reloads the thread, so the list has to scroll to it again.
const lastScrolledCenteredRef = React.useRef<T.Chat.Ordinal | undefined>(undefined)
React.useLayoutEffect(() => {
lastScrolledCenteredRef.current = undefined
}, [datasetKey])

// Owns the in-flight centering loop. It has to outlive re-renders: the messages that make
// centering accurate arrive after it starts, so the loop must not be torn down by an effect
// cleanup when messageOrdinals changes. Only a new target or unmount stops it.
const centerLoopRef = React.useRef<{cancelled: boolean} | undefined>(undefined)
// The loop re-centers for up to ~3s; a user scrolling in that window must win.
const abortCentering = React.useCallback(() => {
if (centerLoopRef.current) centerLoopRef.current.cancelled = true
}, [])
React.useEffect(() => abortCentering, [abortCentering])

// Closed loop, not one shot: rows enter at estimatedItemSize and only settle as they measure, so
// the first scroll lands off by however wrong the estimates above the target were. Measure the
// row's real offset from the viewport center and correct until it holds still, then get out of
// the way: maintainVisibleContentPosition owns the offset from then on. Two controllers fighting
// over the same scroll offset would oscillate.
//
// Correct via LegendList's own scrollToOffset, never scrollIntoView: touching scrollTop directly
// desyncs LegendList's internal scroll state, and the next time it recomputes item positions it
// snaps somewhere unrelated.
const scrollToCentered = React.useEffectEvent((target: T.Chat.Ordinal) => {
abortCentering()
const loop = {cancelled: false}
centerLoopRef.current = loop
const run = async () => {
let settled = 0
let pinnedChecks = 0
let scrollAtLastRequest: number | undefined
for (let elapsed = 0; elapsed < 3000 && !loop.cancelled; ) {
const wrapper = wrapperRef.current as unknown as {
getBoundingClientRect: () => {height: number; top: number}
querySelector: (s: string) => {getBoundingClientRect: () => {height: number; top: number}} | null
} | null
const el = wrapper ? wrapper.querySelector(`[data-ordinal="${target}"]`) : null
if (!wrapper || !el) {
// Target is outside the rendered window; get it mounted first.
const idx = sortedIndexOf(
messageOrdinalsRef.current as unknown as number[],
target as unknown as number
)
if (idx >= 0) {
void listRef.current?.scrollToIndex({animated: false, index: idx, viewPosition: 0.5})
}
settled = 0
pinnedChecks = 0
await new Promise<void>(resolve => setTimeout(resolve, 100))
elapsed += 100
continue
}
const elRect = el.getBoundingClientRect()
const wrapRect = wrapper.getBoundingClientRect()
const offBy = elRect.top + elRect.height / 2 - (wrapRect.top + wrapRect.height / 2)
const scroll = listRef.current?.getState().scroll
// Deadband, not exact centering: below this the row reads as centered, and chasing the
// remainder only fights maintainVisibleContentPosition's own sub-pixel adjustments.
if (Math.abs(offBy) <= centerTolerancePx || scroll === undefined) {
pinnedChecks = 0
// Only the iteration right after a correction can diagnose a clamp.
scrollAtLastRequest = undefined
if (++settled >= 3) return
} else if (scroll === scrollAtLastRequest) {
// A hit near either end of the thread cannot be centered: the offset we ask for gets
// clamped and the row never reaches the middle. Our last correction moved the scroll
// position not at all, so we are pinned against an edge — stop rather than spin.
if (++pinnedChecks >= 3) return
} else {
pinnedChecks = 0
scrollAtLastRequest = scroll
void listRef.current?.scrollToOffset({animated: false, offset: scroll + offBy})
}
await new Promise<void>(resolve => setTimeout(resolve, 50))
elapsed += 50
}
}
void run()
})

React.useEffect(() => {
if (!loaded) return
if (centeredOrdinal !== undefined) {
if (lastScrolledCenteredRef.current === centeredOrdinal) return
const idx = sortedIndexOf(
messageOrdinalsRef.current as unknown as number[],
centeredOrdinal as unknown as number
)
if (idx < 0) return
lastScrolledCenteredRef.current = centeredOrdinal
scrollToCentered(centeredOrdinal)
} else if (lastScrolledCenteredRef.current !== undefined) {
lastScrolledCenteredRef.current = undefined
abortCentering()
if (containsLatestMessage) {
void listRef.current?.scrollToEnd({animated: false})
}
}
}, [abortCentering, centeredOrdinal, loaded, containsLatestMessage, messageOrdinals])
useScrollToCentered({centeredOrdinal, datasetKey, listRef, messageOrdinals, ready: loaded})

// Scroll to the message being edited
const lastEditingOrdinalRef = React.useRef<T.Chat.Ordinal | undefined>(undefined)
Expand Down Expand Up @@ -527,12 +478,6 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() {

const initialScrollIndex = useInitialScrollIndex(messageOrdinals, centeredOrdinal)

// A wheel means the user took over: stop centering so we don't scroll them away from where
// they landed.
const onWheel = React.useCallback(() => {
abortCentering()
}, [abortCentering])

return (
<Kb.ErrorBoundary>
<div
Expand All @@ -541,7 +486,6 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() {
style={Kb.Styles.castStyleDesktop(desktopStyles.container)}
onClick={handleListClick}
onCopyCapture={onCopyCapture}
onWheel={onWheel}
ref={wrapperRef}
>
<LegendList
Expand Down Expand Up @@ -572,7 +516,10 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() {
}
// Stays on while centered: the full thread response lands after the cached one and
// re-measures rows above the target, which slides it out of view unless anchored.
maintainVisibleContentPosition={{data: true}}
// The documented boolean form, which enables both anchors. A partial config object opts
// out of whatever it does not name — `data` defaults to false — so naming keys here would
// silently narrow this the way maintainScrollAtEnd's {on: {...}} list once did.
maintainVisibleContentPosition={true}
onLoad={onLoad}
onScroll={onScroll as unknown as (e: unknown) => void}
onStartReached={onStartReached}
Expand Down
9 changes: 8 additions & 1 deletion shared/chat/conversation/messages/special-top-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import {useConversationParticipantsSelector} from '../data-hooks'
import * as FS from '@/constants/fs'
import {useCurrentUserState} from '@/stores/current-user'
import * as TestIDs from '@/tests/e2e/shared/test-ids'

const ErrorMessage = () => {
const styles = useStyles()
Expand Down Expand Up @@ -153,7 +154,13 @@ function SpecialTopMessage() {
}

return (
<Kb.Box2 direction="vertical" fullWidth={true} style={styles.container}>
<Kb.Box2
direction="vertical"
fullWidth={true}
collapsable={false}
style={styles.container}
testID={TestIDs.CHAT_THREAD_TOP}
>
{hasLoadedEver && loadMoreType === 'noMoreToLoad' && showRetentionNotice && <RetentionNotice />}
<Kb.Box2 direction="vertical" style={styles.spacer} />
{hasOlderResetConversation && <ProfileResetNotice />}
Expand Down
11 changes: 9 additions & 2 deletions shared/chat/conversation/messages/wrapper/wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {emptyParticipantInfo} from '../../data-hooks'
import {useInboxMetadataState} from '@/chat/inbox/metadata'
import type {ConversationInputState} from '../../input-area/input-state'
import {useChatTeamMemberRole} from '../../team-hooks'
import * as TestIDs from '@/tests/e2e/shared/test-ids'

type AccountsInfoMap = ReadonlyMap<T.RPCChat.MessageID, T.Chat.ChatRequestInfo | T.Chat.ChatPaymentInfo>
type PaymentStatusMap = ReadonlyMap<T.Wallets.PaymentID, T.Chat.ChatPaymentInfo>
Expand Down Expand Up @@ -905,7 +906,7 @@ function RightSide(p: RProps) {
)}
>
<Kb.Box2 direction="vertical">
<Kb.Icon type="iconfont-ellipsis" onClick={showPopup} />
<Kb.Icon type="iconfont-ellipsis" onClick={showPopup} testID={TestIDs.CHAT_MESSAGE_MENU_BUTTON} />
</Kb.Box2>
</Kb.Box2>
)
Expand Down Expand Up @@ -1018,7 +1019,13 @@ export function WrapperMessage(p: WrapperMessageProps) {
const messageContext = {isHighlighted: showCenteredHighlight, ordinal}

const row = (
<Kb.Box2 direction="vertical" relative={true} fullWidth={true}>
<Kb.Box2
direction="vertical"
relative={true}
fullWidth={true}
collapsable={false}
testID={showCenteredHighlight ? TestIDs.CHAT_SEARCH_HIT : undefined}
>
<AuthorHeader
author={author}
botAlias={botAlias}
Expand Down
8 changes: 5 additions & 3 deletions shared/chat/conversation/thread-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,11 @@ ConversationThreadIDContext.displayName = 'ConversationThreadIDContext'

export type ConversationThreadState = {
accountsInfoMap: Map<T.RPCChat.MessageID, T.Chat.ChatRequestInfo | T.Chat.ChatPaymentInfo>
// Bumped on every messagesClear. The desktop list remounts on it: LegendList cannot recover
// from a non-empty -> empty -> non-empty data transition (it resets its layout state and waits
// for a container layout event that never comes), so the thread renders blank forever.
// Bumped on every messagesClear, and fed to the list as its dataKey (not as a React key - the
// list is not remounted). LegendList cannot recover from a non-empty -> empty -> non-empty data
// transition on its own (it resets its layout state and waits for a container layout event that
// never comes), so the thread renders blank forever; the dataKey change is what tells it this is
// a new dataset and makes it reset rather than wait.
clearVersion: number
explodingMode: number
flipStatusMap: Map<string, T.RPCChat.UICoinFlipStatus>
Expand Down
8 changes: 7 additions & 1 deletion shared/chat/inbox-and-conversation-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {navToPath} from '@/constants/fs'
import {showConversationInfoPanel, toggleConversationThreadSearch} from '@/chat/conversation/thread-context'
import {muteConversation} from '@/chat/conversation/status-actions'
import AccountSwitchHeaderAvatar from '@/router-v2/account-switch-header-avatar'
import * as TestIDs from '@/tests/e2e/shared/test-ids'

const emptyMeta = Chat.makeConversationMeta()
const emptyParticipantInfo = Chat.uiParticipantsToParticipantInfo([])
Expand Down Expand Up @@ -245,7 +246,12 @@ const Header = () => {
direction="vertical"
tooltip={`Search in this chat (${C.shortcutSymbol}F)`}
>
<Kb.Icon style={styles.clickable} type="iconfont-search" onClick={onToggleThreadSearch} />
<Kb.Icon
style={styles.clickable}
type="iconfont-search"
onClick={onToggleThreadSearch}
testID={TestIDs.CHAT_HEADER_SEARCH_BUTTON}
/>
</Kb.Box2>
<Kb.Box2
className="tooltip-left"
Expand Down
9 changes: 8 additions & 1 deletion shared/chat/inbox/row/big-team-channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type * as React from 'react'
import * as Kb from '@/common-adapters'
import * as RowSizes from './sizes'
import * as T from '@/constants/types'
import * as TestIDs from '@/tests/e2e/shared/test-ids'
import {useInboxRowBig} from '@/chat/inbox/rows-state'
type Props = {
conversationIDKey: string
Expand Down Expand Up @@ -92,7 +93,13 @@ const BigTeamChannel = (props: Props) => {
) : null

return (
<Kb.ClickableBox direction="vertical" fullWidth={true} onClick={onSelectConversation} style={styles.container}>
<Kb.ClickableBox
direction="vertical"
fullWidth={true}
onClick={onSelectConversation}
style={styles.container}
testID={TestIDs.CHAT_INBOX_CHANNEL_ROW}
>
<Kb.Box2 direction="horizontal" fullHeight={true} style={styles.rowContainer}>
<Kb.Box2
className="hover_background_color_blueGreyDark"
Expand Down
Loading