From 3f6f0efe81cdd45da143c3da65055db845602a46 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 2 Sep 2026 09:28:46 +0200 Subject: [PATCH 1/2] test: add CIT-1311 quick-reply scroll repro harness Applied from PR #3793 to reproduce the maintainVisibleContentPosition scroll-anchor bug when an already-measured message grows in place. --- .../ExpoMessagingComponentOverrides.tsx | 10 +- .../QuickReplyMessageBottomView.tsx | 35 +++++ .../components/QuickReplyPills.tsx | 139 ++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 examples/ExpoMessaging/components/QuickReplyMessageBottomView.tsx create mode 100644 examples/ExpoMessaging/components/QuickReplyPills.tsx diff --git a/examples/ExpoMessaging/components/ExpoMessagingComponentOverrides.tsx b/examples/ExpoMessaging/components/ExpoMessagingComponentOverrides.tsx index b978f3482f..13ad96dd92 100644 --- a/examples/ExpoMessaging/components/ExpoMessagingComponentOverrides.tsx +++ b/examples/ExpoMessaging/components/ExpoMessagingComponentOverrides.tsx @@ -4,7 +4,15 @@ import type { ComponentOverrides } from 'stream-chat-expo'; import InputButtons from './InputButtons'; import { MessageLocation } from './LocationSharing/MessageLocation'; +import { QuickReplyMessageBottomView } from './QuickReplyMessageBottomView'; export const useExpoMessagingComponentOverrides = () => { - return useMemo(() => ({ InputButtons, MessageLocation }), []); + return useMemo( + () => ({ + InputButtons, + MessageLocation, + MessageContentBottomView: QuickReplyMessageBottomView, + }), + [], + ); }; diff --git a/examples/ExpoMessaging/components/QuickReplyMessageBottomView.tsx b/examples/ExpoMessaging/components/QuickReplyMessageBottomView.tsx new file mode 100644 index 0000000000..0056166262 --- /dev/null +++ b/examples/ExpoMessaging/components/QuickReplyMessageBottomView.tsx @@ -0,0 +1,35 @@ +import React, { useEffect, useState } from 'react'; + +import { useMessageContext } from 'stream-chat-expo'; + +import QuickReplyPills from './QuickReplyPills'; + +// Minimal CIT-1311 repro harness: mimics Robin's `quick_replies` field +// attaching to an already-rendered, already-measured message some time +// after it first appears. Hardcoded trigger + replies for now — send a +// message containing "1311" and watch the scroll position once the pills +// mount ~1.5s later. +const TRIGGER_KEYWORD = '1311'; +const REPLY_DELAY_MS = 1500; +const HARDCODED_REPLIES = ['Yes', 'No']; + +export const QuickReplyMessageBottomView = () => { + const { message } = useMessageContext(); + const [showPills, setShowPills] = useState(false); + const messageId = message.id; + const messageText = message.text; + + useEffect(() => { + if (!messageText?.includes(TRIGGER_KEYWORD)) { + return; + } + const timeoutId = setTimeout(() => setShowPills(true), REPLY_DELAY_MS); + return () => clearTimeout(timeoutId); + }, [messageId, messageText]); + + if (!showPills) { + return null; + } + + return ; +}; diff --git a/examples/ExpoMessaging/components/QuickReplyPills.tsx b/examples/ExpoMessaging/components/QuickReplyPills.tsx new file mode 100644 index 0000000000..f87d07580d --- /dev/null +++ b/examples/ExpoMessaging/components/QuickReplyPills.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; + +// Ported from Hinge Health's Phoenix app (src/modules/in-app-messaging/components/QuickReplyPills.tsx) +// for a minimal CIT-1311 repro against stock stream-chat-expo. The original pulls these values +// from the `@hinge-health/heal` design system, which isn't available here — hardcoded to the +// same pixel values (heal's `sN` spacing scale is N px; `radius.pill` is a large constant used +// purely to force a fully-rounded pill regardless of height). Colors are placeholders: the bug +// this is reproducing is about layout/scroll timing, not visual fidelity. +const SPACE_4 = 4; +const SPACE_8 = 8; +const SPACE_16 = 16; +const SPACE_20 = 20; +const RADIUS_PILL = 999; +const COLOR_BORDER = '#D6D3D1'; +const COLOR_FILL = '#FFFFFF'; + +// Design-approved cap for LLM-generated reply text — no matching space token +// (this constrains a single pill's width, not inter-element spacing). +const MAX_PILL_WIDTH = 280; +// Design-approved touch target — no matching space token. +const PILL_MIN_HEIGHT = 44; + +const styles = StyleSheet.create({ + container: { + paddingTop: SPACE_4, + }, + contentContainer: { + flexGrow: 1, + justifyContent: 'center', + paddingHorizontal: SPACE_16, + gap: SPACE_16, + }, + // Background/border live on the pill itself, not a wrapper, to avoid + // bleed/seam artifacts (mirrors ConversationStarterPills' PillView). + pill: { + minHeight: PILL_MIN_HEIGHT, + maxWidth: MAX_PILL_WIDTH, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: SPACE_20, + paddingVertical: SPACE_8, + borderRadius: RADIUS_PILL, + borderWidth: 1, + borderColor: COLOR_BORDER, + backgroundColor: COLOR_FILL, + }, + // heal's HLText type="body1" — approximated; not load-bearing for this repro. + pillText: { + fontSize: 16, + lineHeight: 22, + }, +}); + +function QuickReplyPill({ + label, + onPress, +}: Readonly<{ + label: string; + onPress?: () => void; +}>) { + // No handler wired yet — render inert presentation rather than a disabled + // button. A disabled Pressable still announces "button, dimmed" to + // screen readers, telling AT users an action exists when it doesn't. + if (!onPress) { + return ( + + {label} + + ); + } + return ( + + {label} + + ); +} + +type PillDatum = Readonly<{ + key: string; + label: string; + handlePress?: () => void; +}>; + +// Module-level and reads only its own parameter, so it's a stable reference +// across renders — FlatList's item memoization isn't defeated by a fresh +// closure on every render of QuickReplyPills. +function renderPill({ item }: { item: PillDatum }) { + return ; +} + +function keyExtractor(item: PillDatum): string { + return item.key; +} + +// LLM-generated replies can repeat text, so the reply string alone isn't a +// safe React key. Prefixing with the index guarantees uniqueness even when +// multiple replies share identical text. +function buildPillData( + replies: string[], + onPressItem?: (reply: string, index: number) => void, +): PillDatum[] { + return replies.map((label, index) => ({ + key: `${index}-${label}`, + label, + handlePress: onPressItem ? () => onPressItem(label, index) : undefined, + })); +} + +type Props = Readonly<{ + replies: string[]; + // When omitted, pills render as inert presentation (no button role) + onPressItem?: (reply: string, index: number) => void; +}>; + +export default function QuickReplyPills({ replies, onPressItem }: Props) { + const data = buildPillData(replies, onPressItem); + return ( + + + + ); +} From 4b3f584ea731225b6ce4aa23216e5be224b12775 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 2 Sep 2026 10:56:13 +0200 Subject: [PATCH 2/2] fix(ExpoMessaging): stop quick-reply pills stretching the message bubble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `QuickReplyPills` renders a horizontal `FlatList`, and RN applies `flexGrow: 1` to every ScrollView through its internal `baseHorizontal` style. Inside the message bubble's column layout the parent's main axis is height, so the list expanded to consume all available vertical space — a bubble containing the single word "1311" measured 1234px (~617dp), filling the entire message list viewport. That produced two symptoms that together looked like a scroll-position bug in the SDK: - The viewport was covered by one almost-empty bubble, so the list read as blank. - Each row growing ~50px -> ~1234px in place is an enormous mid-flight resize, so the native maintainVisibleContentPosition anchor correction was correspondingly enormous and clamped to `contentHeight - viewportHeight` (measured 1771.7 = 2429.9 - 658.1), parking the list at the oldest end before smooth-scrolling back. Setting `flexGrow: 0` on the list's own `style` overrides RN's default (`props.style` composes over the base style) and lets the list size to its content. Measured on a Galaxy A12: bubbles 158px, all pills rendered, list pinned to the newest message, no scroll correction. Preferred over pinning `height`/`maxHeight` because it addresses the cause rather than fencing it in, needs no magic number kept in sync with the pill metrics, and lets the row grow for large system font sizes instead of clipping. Verified on Android only; iOS is untested. Co-Authored-By: Claude Opus 5 (1M context) --- examples/ExpoMessaging/components/QuickReplyPills.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/ExpoMessaging/components/QuickReplyPills.tsx b/examples/ExpoMessaging/components/QuickReplyPills.tsx index f87d07580d..d35445cb9f 100644 --- a/examples/ExpoMessaging/components/QuickReplyPills.tsx +++ b/examples/ExpoMessaging/components/QuickReplyPills.tsx @@ -25,6 +25,9 @@ const styles = StyleSheet.create({ container: { paddingTop: SPACE_4, }, + list: { + flexGrow: 0, + }, contentContainer: { flexGrow: 1, justifyContent: 'center', @@ -128,6 +131,7 @@ export default function QuickReplyPills({ replies, onPressItem }: Props) { renderItem={renderPill} keyExtractor={keyExtractor} horizontal + style={styles.list} showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps='handled' testID='quick-reply-pills-flatlist'