Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentOverrides>(() => ({ InputButtons, MessageLocation }), []);
return useMemo<ComponentOverrides>(
() => ({
InputButtons,
MessageLocation,
MessageContentBottomView: QuickReplyMessageBottomView,
}),
[],
);
};
35 changes: 35 additions & 0 deletions examples/ExpoMessaging/components/QuickReplyMessageBottomView.tsx
Original file line number Diff line number Diff line change
@@ -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 <QuickReplyPills replies={HARDCODED_REPLIES} />;
};
143 changes: 143 additions & 0 deletions examples/ExpoMessaging/components/QuickReplyPills.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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,
},
list: {
flexGrow: 0,
},
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 (
<View style={styles.pill}>
<Text style={styles.pillText}>{label}</Text>
</View>
);
}
return (
<Pressable
style={styles.pill}
onPress={onPress}
disabled={false}
accessibilityRole='button'
accessibilityLabel={label}
>
<Text style={styles.pillText}>{label}</Text>
</Pressable>
);
}

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 <QuickReplyPill label={item.label} onPress={item.handlePress} />;
}

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 (
<View style={styles.container}>
<FlatList
data={data}
renderItem={renderPill}
keyExtractor={keyExtractor}
horizontal
style={styles.list}
showsHorizontalScrollIndicator={false}
keyboardShouldPersistTaps='handled'
testID='quick-reply-pills-flatlist'
contentContainerStyle={styles.contentContainer}
accessibilityHint='Scrollable list of quick replies'
/>
</View>
);
}
Loading