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
641 changes: 304 additions & 337 deletions shared/chat/conversation/list-area/index.tsx

Large diffs are not rendered by default.

32 changes: 23 additions & 9 deletions shared/chat/conversation/messages/row-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ test('showUsername is derived from the previous ordinal and current message data
).toBe('bob')
})

test('row type preserves native recycle distinctions', () => {
test('row type only uses suffixes that are stable for the message lifetime', () => {
// pending flips to confirmed after every send; reactions toggle. Both would leave stale
// recycling-pool labels behind, so they must NOT affect the row type.
const pending = makeTextMessage({
id: T.Chat.numberToMessageID(401),
ordinal: T.Chat.numberToOrdinal(401),
Expand Down Expand Up @@ -140,10 +142,10 @@ test('row type preserves native recycle distinctions', () => {
reactions: new Map([[':+1:', makeReaction('bob', 5)]]),
})

expect(getMessageRowType(pending)).toBe('text:pending')
expect(getMessageRowType(failed)).toBe('text:pending')
expect(getMessageRowType(pending)).toBe('text')
expect(getMessageRowType(failed)).toBe('text:failed')
expect(getMessageRowType(reply)).toBe('text:reply')
expect(getMessageRowType(reaction)).toBe('text:reactions')
expect(getMessageRowType(reaction)).toBe('text')
})

test('showUsername recomputes from the current neighboring ordinal after inserts and deletes', () => {
Expand Down Expand Up @@ -211,7 +213,7 @@ test('showUsername recomputes from the current neighboring ordinal after inserts
).toBe('bob')
})

test('row type combines pending, reply, and reaction suffixes after row edits', () => {
test('row type combines stable suffixes and is unchanged by send confirmation', () => {
const reply = makeTextMessage({
id: T.Chat.numberToMessageID(600),
ordinal: T.Chat.numberToOrdinal(600),
Expand All @@ -223,21 +225,33 @@ test('row type combines pending, reply, and reaction suffixes after row edits',
replyTo: reply,
submitState: 'pending',
})
const failedReply = makeTextMessage({
errorReason: 'send failed',
id: T.Chat.numberToMessageID(603),
ordinal: T.Chat.numberToOrdinal(603),
replyTo: reply,
submitState: 'failed',
})
const failedAttachment = makeAttachmentMessage({
errorReason: 'upload failed',
id: T.Chat.numberToMessageID(602),
ordinal: T.Chat.numberToOrdinal(602),
submitState: 'failed',
})

expect(getMessageRowType(pendingReplyWithReaction)).toBe('text:pending:reply:reactions')
expect(getMessageRowType(failedAttachment)).toBe('attachment:pending')
expect(getMessageRowType(pendingReplyWithReaction)).toBe('text:reply')
expect(getMessageRowType(failedReply)).toBe('text:failed:reply')
expect(getMessageRowType(failedAttachment)).toBe('attachment:failed')

const edited = makeTextMessage({
// confirmation (pending → sent) must not change the type: the recycling pool label was recorded
// at allocation and is never updated in place
const confirmed = makeTextMessage({
id: T.Chat.numberToMessageID(601),
ordinal: T.Chat.numberToOrdinal(601),
reactions: new Map([[':+1:', makeReaction('bob', 5)]]),
replyTo: reply,
submitState: undefined,
})

expect(getMessageRowType(edited)).toBe('text')
expect(getMessageRowType(confirmed)).toBe('text:reply')
})
16 changes: 7 additions & 9 deletions shared/chat/conversation/messages/row-metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,22 +70,20 @@ export const getMessageRowRecycleType = (
let rowRecycleType = baseType
let needsSpecificRecycleType = false

if (
(message.type === 'text' || message.type === 'attachment') &&
(message.submitState === 'pending' || message.submitState === 'failed')
) {
rowRecycleType += ':pending'
// Only suffixes that are stable for the message's lifetime: the recycling pool label is recorded
// when a container is allocated and never updated on in-place changes, so a suffix that can flip
// (pending → confirmed after every send, reactions toggling on and off) leaves stale pool labels
// behind and recycled containers paint at the wrong pooled height. 'failed' is sticky until an
// explicit retry and 'reply' never changes.
if ((message.type === 'text' || message.type === 'attachment') && message.submitState === 'failed') {
rowRecycleType += ':failed'
needsSpecificRecycleType = true
}

if (message.type === 'text' && message.replyTo) {
rowRecycleType += ':reply'
needsSpecificRecycleType = true
}
if (message.reactions?.size) {
rowRecycleType += ':reactions'
needsSpecificRecycleType = true
}

return needsSpecificRecycleType ? rowRecycleType : undefined
}
Expand Down
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
27 changes: 27 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,27 @@
import * as React from 'react'
import type * as T from '@/constants/types'
import {useSyncLayout} from '@legendapp/list/react-native'
import {useOrdinal} from './ids-context'

// 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. Flushes only when the signature changes for the SAME
// message: the initial mount and a recycled container switching to a new ordinal both get measured
// by LegendList's own onLayout, so flushing there is wasted sync layout work mid-scroll. Noops
// wherever the row is not inside a LegendList container, or on the old architecture.
export const useSyncRowLayout = (signature: string | number) => {
const ordinal = useOrdinal()
const syncLayout = useSyncLayout()
const lastRef = React.useRef<{ordinal: T.Chat.Ordinal; signature: string | number} | undefined>(
undefined
)
React.useLayoutEffect(() => {
const last = lastRef.current
lastRef.current = {ordinal, signature}
if (last?.ordinal !== ordinal) return
if (last.signature === signature) return
syncLayout()
}, [ordinal, signature, syncLayout])
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ const DesktopExplodingHeightRetainer = (p: Props) => {
doneKey: retainHeight ? messageKey : undefined,
retainHeight,
}))
const [height, setHeight] = React.useState(17)
// keyed to the message: with list recycling this instance is reused for other messages, and a
// stale measured height would be retained as the wrong row height
const [heightState, setHeightState] = React.useState(() => ({height: 17, key: messageKey}))
if (heightState.key !== messageKey) {
setHeightState({height: 17, key: messageKey})
}
const height = heightState.height

let currentAnimationState = animationState
if (animationState.retainHeight !== retainHeight) {
Expand Down Expand Up @@ -64,7 +70,7 @@ const DesktopExplodingHeightRetainer = (p: Props) => {
const setBoxRef = React.useCallback((ref: Kb.MeasureRef | null) => {
const measuredHeight = ref?.getBoundingClientRect().height
if (measuredHeight) {
setHeight(lastHeight => (lastHeight === measuredHeight ? lastHeight : measuredHeight))
setHeightState(prev => (prev.height === measuredHeight ? prev : {...prev, height: measuredHeight}))
}
}, [])

Expand Down Expand Up @@ -162,9 +168,18 @@ function FlameFront(props: {height: number; stop: boolean}) {
// Native implementation
const NativeExplodingHeightRetainer = (p: Props) => {
const {retainHeight, explodedBy, messageKey, style, children} = p
const [height, setHeight] = React.useState(20)
// keyed to the message: with recycleItems this instance is reused for other messages, and a
// stale measured height would be applied as the retained height of the wrong row — and since
// retainHeight forces the style height, onLayout would only ever report the forced value back,
// so it could never self-correct
const [heightState, setHeightState] = React.useState(() => ({height: 20, key: messageKey}))
if (heightState.key !== messageKey) {
setHeightState({height: 20, key: messageKey})
}
const height = heightState.height
const onLayout = (evt: Kb.LayoutEvent) => {
setHeight(evt.nativeEvent.layout.height)
const h = evt.nativeEvent.layout.height
setHeightState(prev => (prev.height === h ? prev : {...prev, height: h}))
}
const numImages = Math.ceil(height / 80)

Expand All @@ -181,10 +196,12 @@ const NativeExplodingHeightRetainer = (p: Props) => {
])}
>
{retainHeight ? null : children}
{/* keyed so recycling to a different message remounts the tower — its animation state
(slider value, exploded tag) is per-message */}
<AnimatedAshTower
key={messageKey}
exploded={retainHeight}
explodedBy={explodedBy}
messageKey={messageKey}
numImages={numImages}
/>
</Kb.Box2>
Expand All @@ -194,7 +211,6 @@ const NativeExplodingHeightRetainer = (p: Props) => {
type AshTowerProps = {
exploded: boolean
explodedBy?: string
messageKey: string
numImages: number
}

Expand Down
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 {ThreadRefsContext} 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(ThreadRefsContext)
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
8 changes: 5 additions & 3 deletions shared/chat/conversation/messages/wrapper/sent.native.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type * as React from 'react'
import Animated, {FadeInDown} from 'react-native-reanimated'

// Slide-up + fade for a message you just sent. The thread list is an inverted
// FlatList (cells are flipped with scaleY: -1), so FadeInDown renders on screen
// as sliding up from below. Runs entirely on the UI thread with no re-renders.
// Slide-up + fade for a message you just sent. The thread list (LegendList) is
// NOT inverted, so FadeInDown (enters from 25px below, sliding up into place)
// reads as the row rising from the input bar. Runs entirely on the UI thread
// with no re-renders. The entering animation only plays when this Animated.View
// MOUNTS — callers must key it per message (recycled containers reuse instances).
export function Sent(p: {children: React.ReactNode}) {
return (
<Animated.View entering={FadeInDown.duration(200)} style={styles.container}>
Expand Down
29 changes: 24 additions & 5 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 @@ -560,6 +561,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 Expand Up @@ -913,7 +917,6 @@ function RightSide(p: RProps) {
export function WrapperMessage(p: WrapperMessageProps) {
const {ordinal, bottomChildren, children, messageData: mdata} = p
const {showPopup, showingPopup, popup, popupAnchor} = p
const [showingPicker, setShowingPicker] = React.useState(false)

const {decorate, type, hasReactions, isEditing, shouldShowPopup} = mdata
const {canShowReactionsPopup, ecrType, exploded, explodesAt, forceExplodingRetainer, messageKey} = mdata
Expand All @@ -924,9 +927,23 @@ export function WrapperMessage(p: WrapperMessageProps) {
const {setEditing, setReplyTo, toggleMessageReaction} = mdata
const {author, botAlias, isAdhocBot, showUsername, teamID, teamType, teamname, timestamp} = mdata

// captured at mount: only the row created by your send animates, and the tree
// shape stays stable when the message is later confirmed (youSent flips false)
const [animateSent] = React.useState(isMobile && mdata.youSent)
// Both pieces of per-row state are keyed to messageKey and reset when it changes: with
// recycleItems the component instance is REUSED for a different message, so plain mount-captured
// state would leak across rows (picker open on the wrong row, sent-animation wrapper stuck on).
// Same-message updates keep the captured value, so the tree stays stable when the message is
// later confirmed (youSent flips false).
const [rowState, setRowState] = React.useState(() => ({
animateSent: isMobile && mdata.youSent,
key: messageKey,
showingPicker: false,
}))
if (rowState.key !== messageKey) {
setRowState({animateSent: isMobile && mdata.youSent, key: messageKey, showingPicker: false})
}
const {animateSent, showingPicker} = rowState
const setShowingPicker = React.useCallback((s: boolean) => {
setRowState(prev => (prev.showingPicker === s ? prev : {...prev, showingPicker: s}))
}, [])

const isHighlighted = showCenteredHighlight || isEditing
const tsprops = {
Expand Down Expand Up @@ -992,7 +1009,9 @@ export function WrapperMessage(p: WrapperMessageProps) {

return (
<MessageContext value={messageContext}>
{animateSent ? <Sent>{row}</Sent> : row}
{/* keyed so a recycled container going straight from one of your sends to another remounts
the Animated.View — the entering animation only plays on mount */}
{animateSent ? <Sent key={messageKey}>{row}</Sent> : row}
{popup}
</MessageContext>
)
Expand Down
3 changes: 2 additions & 1 deletion shared/common-adapters/swipeable-row.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const springConfig = {friction: 20, tension: 150, useNativeDriver: false} as con
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 @@ -128,7 +129,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
3 changes: 3 additions & 0 deletions shared/common-adapters/swipeable-row.shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ export 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
}