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
22 changes: 17 additions & 5 deletions shared/chat/conversation/input-area/normal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,24 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() {
const {scrollToBottom} = React.useContext(ThreadRefsContext)
const onSubmit = (text: string) => {
if (!text) return
// Clearing the composer shrinks it back to one line, which grows the thread's viewport. Sending in
// the same tick makes that growth and the new row a single change for the list to resolve its end
// against, and it lands short — 8 of 8 at one, two and six lines, worse the longer the message. So
// clear first and let that land before the row arrives. legend-list's own chat example does both at
// once, which works there because its composer is a single-line input that never resizes the list.
//
// A timeout rather than requestAnimationFrame: this callback owns the only copy of the text, and
// frames stop in a hidden or backgrounded window, which would drop the message with the composer
// already emptied.
injectText('', true)
sendComposerText(text)
if (hasCenter) {
toggleThreadSearch(true)
jumpToRecent()
} else {
setTimeout(() => {
sendComposerText(text)
if (hasCenter) {
toggleThreadSearch(true)
jumpToRecent()
}
}, 0)
if (!hasCenter) {
scrollToBottom()
}
}
Expand Down
40 changes: 36 additions & 4 deletions shared/chat/conversation/list-area/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ const usePagination = (p: {
}

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

// When a centeredOrdinal is set at mount, start there; otherwise start at the end (newest).
const useInitialScrollIndex = (
Expand Down Expand Up @@ -237,10 +239,29 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() {

const getItemType = useGetItemType()

// Imperative scroll for ThreadRefsContext
// Asks the scroller, not the list's own isAtEnd: that flag comes from the content size and viewport
// the list has recorded, and both lag a composer collapse, so it reads not-at-end while the scroller
// is in fact at its end.
const isScrolledToEnd = React.useCallback(() => {
type ElLike = {children: ArrayLike<ElLike>; clientHeight: number; scrollHeight: number; scrollTop: number}
const wrapper = wrapperRef.current as unknown as ElLike | null
if (!wrapper) return false
for (const child of Array.from(wrapper.children)) {
if (child.scrollHeight - child.clientHeight > 1) {
return child.scrollHeight - child.clientHeight - child.scrollTop <= endTolerancePx
}
}
return false
}, [])

// Imperative scroll for ThreadRefsContext: for coming back from somewhere else in the thread, which
// is the only case that needs it. While the list is at the end maintainScrollAtEnd owns the position,
// and scrolling here only displaces it — the target resolves before the new row has measured, so it
// lands short, and while it counts as in flight the list declines its own end anchor and abandons it.
const scrollToBottom = React.useCallback(() => {
if (isScrolledToEnd()) return
void listRef.current?.scrollToEnd({animated: false})
}, [])
}, [isScrolledToEnd])

const scrollUp = React.useCallback(() => {
const state = listRef.current?.getState()
Expand Down Expand Up @@ -536,12 +557,18 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() {
drawDistance={250}
estimatedItemSize={72}
style={Kb.Styles.castStyleDesktop(desktopStyles.list)}
// Short threads sit at the bottom rather than the top. Inert once the content is taller than
// the viewport: the padding it adds is max(0, viewport - content).
alignItemsAtEnd={true}
initialScrollAtEnd={initialScrollIndex === undefined}
initialScrollIndex={initialScrollIndex}
maintainScrollAtEnd={
centeredOrdinal !== undefined
? false
: {on: {dataChange: true, footerLayout: true, itemLayout: true}}
: // The documented form, which enables every trigger. It was a narrowed {on: {...}} list
// before, and naming any trigger opts out of the ones left unnamed — that is how the
// layout trigger went missing and a window resize lost the end.
true
}
// 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.
Expand All @@ -565,6 +592,12 @@ const useDesktopStyles = Kb.Styles.createStyleHook(
isElectron: {
...Kb.Styles.globalStyles.fillAbsolute,
overflow: 'hidden',
// The gap above the input lives out here, not as the list's own paddingBottom: the list
// feeds its padding into every scroll-offset calculation it makes (content size, the end
// target, the at-end threshold), so keeping it outside the scroller keeps that math on
// message sizes alone. Deliberately 8 rather than the 16 it used to be — half the gap reads
// better with the messages sitting closer to the composer.
paddingBottom: 8,
},
}),
list: Kb.Styles.platformStyles({
Expand All @@ -573,7 +606,6 @@ const useDesktopStyles = Kb.Styles.createStyleHook(
outline: 'none',
overflowY: 'auto',
overscrollBehavior: 'contain',
paddingBottom: 16,
scrollbarGutter: 'stable',
willChange: 'transform',
},
Expand Down
37 changes: 30 additions & 7 deletions shared/chat/conversation/messages/emoji-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
useConversationThreadMessageActions,
} from '../thread-context'

// A raised hover bar stops this far above the scroller's bottom edge instead of flush against it.
const edgeClearance = 2

type OwnProps = {
className?: string
hasUnfurls: boolean
Expand Down Expand Up @@ -74,7 +77,6 @@ function EmojiRowContainer(p: OwnProps) {
// on every pointer re-entry into the row. Typed structurally since the native tsconfig has no
// DOM lib; on desktop Box2's MeasureRef is the backing div.
const [raiseBy, setRaiseBy] = React.useState(0)
const raiseByRef = React.useRef(0)
React.useLayoutEffect(() => {
if (isMobile || !keepWithinList) {
return
Expand All @@ -83,24 +85,45 @@ function EmojiRowContainer(p: OwnProps) {
addEventListener: (t: string, cb: () => void) => void
removeEventListener: (t: string, cb: () => void) => void
getBoundingClientRect: () => {bottom: number; height: number}
parentElement: ElLike | null
}
const el = popupAnchor.current as null | (ElLike & {closest?: (sel: string) => ElLike | null})
const row = el?.closest?.('.WrapperMessage-hoverBox')
const list = el?.closest?.('.chat-message-list')
const outer = el?.closest?.('.chat-message-list')
// Measure against the scroller, not the outer box: the gap above the input area is padding on
// the outer box, so the scroller ends above it and clips a bar the outer box still had room
// for. The scroller is the one ancestor sitting directly inside the outer box — don't look for
// "nearest scrollable ancestor", every row container reports as one.
let list = outer
for (let node = el?.parentElement; node; node = node.parentElement) {
if (node.parentElement === outer) {
list = node
break
}
}
if (!el || !row || !list) {
return
}
// Read back the raise the DOM is actually carrying rather than the one we last asked for: the two
// differ for as long as it takes React to paint our state, and measuring twice inside that gap
// (which mount + the enter event do) would otherwise stack the raise onto itself.
const appliedRaise = () => {
const getStyle = (globalThis as unknown as {getComputedStyle?: (e: unknown) => {transform: string}})
.getComputedStyle
const matrix = /matrix\([^)]*,\s*(-?[\d.]+)\)\s*$/.exec(getStyle?.(el).transform ?? '')
return matrix ? -Number(matrix[1]) : 0
}
const measure = () => {
const rect = el.getBoundingClientRect()
// zero height means we're display:none and the rect is meaningless
if (rect.height === 0) {
return
}
// add back the raise we're currently applying so overflow is computed from our natural spot
const overflow = rect.bottom + raiseByRef.current - list.getBoundingClientRect().bottom
const next = Math.max(0, overflow)
raiseByRef.current = next
setRaiseBy(next)
// add back the raise in effect so overflow is computed from our natural spot
const overflow = rect.bottom + appliedRaise() - list.getBoundingClientRect().bottom
// clear the edge rather than sitting flush on it, so a raised bar doesn't read as tight
// against the input area
setRaiseBy(overflow > 0 ? overflow + edgeClearance : 0)
}
// the enter event that mounted us already fired, so measure directly too
measure()
Expand Down
28 changes: 14 additions & 14 deletions shared/ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PODS:
- EXConstants (57.0.11):
- EXConstants (57.0.12):
- ExpoModulesCore
- Expo (57.0.13):
- Expo (57.0.14):
- ExpoModulesCore
- ExpoModulesJSI
- hermes-engine
Expand All @@ -27,7 +27,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- ExpoAsset (57.0.11):
- ExpoAsset (57.0.12):
- ExpoModulesCore
- ExpoAudio (57.0.3):
- ExpoModulesCore
Expand Down Expand Up @@ -58,15 +58,15 @@ PODS:
- SDWebImageAVIFCoder (~> 0.11.0)
- SDWebImageSVGCoder (~> 1.7.0)
- SDWebImageWebPCoder (~> 0.14.6)
- ExpoImagePicker (57.0.10):
- ExpoImagePicker (57.0.11):
- ExpoModulesCore
- ExpoKeepAwake (57.0.1):
- ExpoModulesCore
- ExpoLocalization (57.0.1):
- ExpoModulesCore
- ExpoLocation (57.0.10):
- ExpoLocation (57.0.11):
- ExpoModulesCore
- ExpoLogBox (57.0.2):
- ExpoLogBox (57.0.3):
- React-Core
- ExpoMailComposer (57.0.1):
- ExpoModulesCore
Expand Down Expand Up @@ -112,7 +112,7 @@ PODS:
- ExpoModulesCore
- ExpoSMS (57.0.1):
- ExpoModulesCore
- ExpoTaskManager (57.0.10):
- ExpoTaskManager (57.0.11):
- ExpoModulesCore
- UMAppLoader
- ExpoVideo (57.0.2):
Expand Down Expand Up @@ -2891,9 +2891,9 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"

SPEC CHECKSUMS:
EXConstants: 13cc6ad9324d81223386074a523d4e16aed32cd2
Expo: 02ec524ae0eda32b052decb8ea9b6c2f1c85e456
ExpoAsset: e046c820a585f46969182c2aa03c3a822dd55b6b
EXConstants: 092a945e490d473e0b6aec724684b36c447dd62a
Expo: 0a22949b8fd3e8e544aa8bd5a75cb36f932289ea
ExpoAsset: 1a913b91ccf6db8a5f68ba0c3ab75b8683766cf1
ExpoAudio: b69f36525132edba8d87a715b3a20c678ef78b30
ExpoCamera: a5e9d9ce2fc06689eb2dd676f1f8d5f22f8aa292
ExpoCameraBarcodeScanning: b869ac87ebe30bb99b26c55b0e0471301383c90b
Expand All @@ -2905,11 +2905,11 @@ SPEC CHECKSUMS:
ExpoFont: 59e1faf66ba9bcd232ae1e2ce58d202a48b6f65e
ExpoHaptics: 4503d2da51ff7109712ccdfe0e4939f66818183e
ExpoImage: b37accc4b12647e5cf939b2335eea2bdb874ad7f
ExpoImagePicker: 945c7f41749d99266d220a67118ae769b4d2af19
ExpoImagePicker: b6f0ce415819462b7cf28c47e7a9e147624ef29e
ExpoKeepAwake: c26f14275017370cc8a4b7b43a0e23361f2053a5
ExpoLocalization: 659243b03b3a3e9589793cee2f728a899e181329
ExpoLocation: 1da1cdf04d24886ca57a98fa36704fa3d5e23629
ExpoLogBox: 6bb73c341aca22e699bd4da6cc61afc844e1a648
ExpoLocation: 30cc8a3337823bf3725a332a96c07ce1d1521875
ExpoLogBox: 2c4225a62f1cfb943e6b2df4fa4fe2d136e06fb6
ExpoMailComposer: 101933dc6bdb4d7a46495d1dd87420365325adb9
ExpoMediaLibrary: 344f5b528a71f34794e9d7396e32589531df146e
ExpoModulesCore: 3b0be7a40e69428089fe7ac7054586a20778d276
Expand All @@ -2918,7 +2918,7 @@ SPEC CHECKSUMS:
ExpoModulesWorkletsAdapter: 4825908c915e61b9e4408c893a66307221269c8f
ExpoScreenCapture: 6b82aba55db3ecc6f124ebd45163d6db9b916b2d
ExpoSMS: ac35f6c85b72ee5b18a7c6bb06550fbd4a683775
ExpoTaskManager: 83460ba590ec911d8eb66c38a49de817b127e0e8
ExpoTaskManager: 764fd9262a9db7a194b58eba220035a7fbd825e8
ExpoVideo: b1d9a325ddcd4f3ed8ccb374c01b688e98bc6a04
FBLazyVector: 3c3be9a019176b5699455f7f66c5444b78a411c6
hermes-engine: 034fad1f720e9fb9fe1b5ae5ac0c217531216c50
Expand Down
15 changes: 8 additions & 7 deletions shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
"@gorhom/bottom-sheet": "5.2.14",
"@gorhom/portal": "1.0.14",
"@khanacademy/simple-markdown": "3.0.1",
"@legendapp/list": "3.3.5",
"@legendapp/list": "3.3.6",
"@msgpack/msgpack": "3.1.3",
"@react-native-community/netinfo": "12.0.1",
"@react-native-masked-view/masked-view": "0.3.2",
Expand All @@ -104,8 +104,8 @@
"date-fns": "4.4.0",
"emoji-datasource-apple": "16.0.0",
"emoji-regex": "10.6.0",
"expo": "57.0.13",
"expo-asset": "57.0.11",
"expo": "57.0.14",
"expo-asset": "57.0.12",
"expo-audio": "57.0.3",
"expo-camera": "57.0.3",
"expo-clipboard": "57.0.1",
Expand All @@ -114,17 +114,17 @@
"expo-file-system": "57.0.4",
"expo-haptics": "57.0.1",
"expo-image": "57.0.3",
"expo-image-picker": "57.0.10",
"expo-image-picker": "57.0.11",
"expo-localization": "57.0.1",
"expo-location": "57.0.10",
"expo-location": "57.0.11",
"expo-mail-composer": "57.0.1",
"expo-media-library": "57.0.4",
"expo-screen-capture": "57.0.2",
"expo-sms": "57.0.1",
"expo-task-manager": "57.0.10",
"expo-task-manager": "57.0.11",
"expo-video": "57.0.2",
"google-libphonenumber": "3.2.46",
"immer": "11.1.16",
"immer": "11.1.17",
"lodash": "4.18.1",
"lottie-react-native": "7.4.0",
"lottie-web": "5.13.0",
Expand Down Expand Up @@ -204,6 +204,7 @@
},
"resolutions": {
"**/@types/react": "19.2.18",
"**/deepmerge-ts": "8.0.1",
"**/serialize-javascript": "7.1.0",
"**/xcode/uuid": "14.0.1"
},
Expand Down
File renamed without changes.
Loading