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
601 changes: 266 additions & 335 deletions shared/chat/conversation/list-area/index.tsx

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions shared/chat/conversation/messages/text/coinflip/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {useOrdinal} from '@/chat/conversation/messages/ids-context'
import {pluralize} from '@/util/string'
import {useConversationThreadMessage, useConversationThreadSelector} from '../../../thread-context'
import {useConversationSendActions} from '../../../send-actions'
import {useSyncRowLayout} from '../../use-sync-row-layout'

// The flip result arrives via a separate status notification, not with the thread, so on initial
// load (an already-finished flip) the card first-paints with no result and then grows when the
Expand Down Expand Up @@ -47,6 +48,11 @@ function CoinFlipContainer() {
const showParticipants = phase === T.RPCChat.UICoinFlipPhase.complete
const numParticipants = participants?.length ?? 0

// The flip result streams in after first paint and grows the card; flush the row measure so the
// list re-pins to the newest message instead of parking above it. Keyed on the status signals
// that change the card height (loaded yet, phase, participant count, result present).
useSyncRowLayout(`${status === undefined ? 0 : 1}|${phase ?? -1}|${numParticipants}|${resultInfo ? 1 : 0}`)

const revealed =
participants?.reduce((r, p) => {
return r + (p.reveal ? 1 : 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {clampImageSize} from '@/constants/chat/helpers'
import {maxWidth} from '@/chat/conversation/messages/attachment/shared'
import {Video} from './video'
import {openURL} from '@/util/misc'
import {useSyncRowLayout} from '@/chat/conversation/messages/use-sync-row-layout'

export type Props = {
autoplayVideo: boolean
Expand All @@ -28,6 +29,10 @@ const UnfurlImage = (p: Props) => {
const maxSize = Math.min(maxWidth, 320) - (widthPadding || 0)
const {height, width} = clampImageSize(p.width, p.height, maxSize, 320)

// Usually the metadata dimensions are known at first paint, but if they arrive in a later update
// the image grows; flush the row measure so the list re-pins instead of parking above newest.
useSyncRowLayout(`${width}x${height}`)

return isVideo ? (
<Video
autoPlay={autoplayVideo}
Expand Down
20 changes: 20 additions & 0 deletions shared/chat/conversation/messages/use-sync-row-layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as React from 'react'
import {useSyncLayout} from '@legendapp/list/react-native'

// When a row's content settles to a new height after first paint (a flip result streams in,
// reactions appear, an unfurl loads), force LegendList to re-measure this row synchronously so its
// bottom-anchoring / re-pin uses the final height on the same frame instead of a frame late (which
// otherwise leaves the thread parked above the newest message). Pass a signature that changes when
// the height-affecting content changes; the initial mount is skipped since LegendList measures that
// via its own onLayout. Noops on desktop / old architecture (useSyncLayout returns noop there).
export const useSyncRowLayout = (signature: string | number) => {
const syncLayout = useSyncLayout()
const firstRef = React.useRef(true)
React.useLayoutEffect(() => {
if (firstRef.current) {
firstRef.current = false
return
}
syncLayout()
}, [signature, syncLayout])
}
17 changes: 13 additions & 4 deletions shared/chat/conversation/messages/wrapper/long-pressable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Props = {
import {useConversationThreadToggleSearch} from '../../../thread-context'
import Swipeable, {type SwipeableMethods} from '@/common-adapters/swipeable-row'
import {FocusContext} from '@/chat/conversation/normal/context'
import {useAdaptiveRender} from '@legendapp/list/react-native'

function ReplyIcon({progress}: {progress: Animated.Value}) {
const opacity = progress.interpolate({inputRange: [-20, 0], outputRange: [1, 0], extrapolate: 'clamp'})
Expand All @@ -27,15 +28,22 @@ function ReplyIcon({progress}: {progress: Animated.Value}) {
}

function LongPressable(props: Props & {ref?: React.Ref<Kb.MeasureRef>}) {
if (!isMobile) {
return <Kb.Box2 direction="horizontal" fullWidth={true} {...props} />
}
return <LongPressableMobile {...props} />
}

function LongPressableMobile(props: Props & {ref?: React.Ref<Kb.MeasureRef>}) {
const toggleThreadSearch = useConversationThreadToggleSearch()
const setReplyTo = InputState.useConversationInputDispatch(s => s.setReplyTo)
const ordinal = useOrdinal()
const {focusInput} = React.useContext(FocusContext)
const swipeRef = React.useRef<SwipeableMethods | null>(null)

if (!isMobile) {
return <Kb.Box2 direction="horizontal" fullWidth={true} {...props} />
}
// Velocity-driven signal from LegendList: during fast scroll it flips to "light". We keep the
// Swipeable mounted (toggling its tree would remount children and flash images) and instead just
// disable its pan handlers in light mode, shedding the per-row touch evaluation during the fling.
const adaptiveMode = useAdaptiveRender()

const {children, onLongPress, style} = props

Expand Down Expand Up @@ -66,6 +74,7 @@ function LongPressable(props: Props & {ref?: React.Ref<Kb.MeasureRef>}) {
return (
<Swipeable
ref={swipeRef}
enabled={adaptiveMode !== 'light'}
renderRightActions={makeAction}
onSwipeableWillOpen={onSwipeableWillOpen}
>
Expand Down
4 changes: 4 additions & 0 deletions shared/chat/conversation/messages/wrapper/wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import ExplodingMeta from './exploding-meta'
import LongPressable from './long-pressable'
import {useMessagePopup} from '../message-popup'
import ReactionsRow from '../reactions-rows'
import {useSyncRowLayout} from '../use-sync-row-layout'
import SendIndicator from './send-indicator'
import * as T from '@/constants/types'
import capitalize from 'lodash/capitalize'
Expand Down Expand Up @@ -544,6 +545,9 @@ function TextAndSiblings(p: TSProps) {
const {hasReactions, popupAnchor, reactions, sendIndicatorFailed, sendIndicatorID} = p
const {sendIndicatorSent, type, setShowingPicker, showCoinsIcon, shouldShowPopup} = p
const {showPopup, showExplodingCountdown, showRevoked, showSendIndicator, showingPicker, submitState} = p
// Reactions appearing and an unfurl card loading both grow the row after first paint; flush the
// measure so the list re-pins to the newest message instead of parking above it.
useSyncRowLayout(`${reactions?.size ?? 0}|${hasUnfurlList ? 1 : 0}`)
const pressableProps = isMobile
? {
onLongPress: decorate && shouldShowPopup ? showPopup : undefined,
Expand Down
6 changes: 5 additions & 1 deletion shared/common-adapters/swipeable-row.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ type Props = {
onSwipeableOpenStartDrag?: () => void
onSwipeableWillOpen?: (direction: 'left') => void
containerStyle?: ViewStyle
// When false, the swipe pan handlers are not attached (children stay mounted, so toggling this
// does NOT remount the row). Used to shed per-row touch evaluation during fast scroll.
enabled?: boolean
}

const springConfig = {friction: 20, tension: 150, useNativeDriver: false} as const

const SwipeableRow = React.forwardRef<SwipeableMethods, Props>(function SwipeableRow(props, ref) {
'use no memo'
const {children, renderRightActions, onSwipeableOpenStartDrag, onSwipeableWillOpen, containerStyle} = props
const {enabled = true} = props

const translationX = React.useRef(new Animated.Value(0)).current
// Separate ref for current value since Animated.Value has no sync .value read
Expand Down Expand Up @@ -142,7 +146,7 @@ const SwipeableRow = React.forwardRef<SwipeableMethods, Props>(function Swipeabl
</View>
</View>
)}
<Animated.View style={animStyle} {...ctx.panHandlers}>
<Animated.View style={animStyle} {...(enabled ? ctx.panHandlers : undefined)}>
{children}
</Animated.View>
</View>
Expand Down
1 change: 1 addition & 0 deletions shared/common-adapters/swipeable-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Props = {
onSwipeableOpenStartDrag?: () => void
onSwipeableWillOpen?: (direction: 'left') => void
containerStyle?: object
enabled?: boolean
}

const SwipeableRow = (_p: Props & {ref?: React.Ref<SwipeableMethods>}) => null
Expand Down