Skip to content

Commit 4134924

Browse files
author
Test
committed
🤖 feat: align RN message chrome with desktop
_Generated with _ Change-Id: I57ed98ae9b9e8459ae773a5eb42d25c6f274cf43 Signed-off-by: Test <test@example.com>
1 parent c276a07 commit 4134924

File tree

3 files changed

+540
-224
lines changed

3 files changed

+540
-224
lines changed
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import type { JSX, ReactNode } from "react";
2+
import React, { useMemo, useState } from "react";
3+
import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from "react-native";
4+
import type { DisplayedMessage } from "@shared/types/message";
5+
import { formatTimestamp } from "@shared/utils/ui/dateTime";
6+
import { Surface } from "../components/Surface";
7+
import { ThemedText } from "../components/ThemedText";
8+
import { useTheme } from "../theme";
9+
import { assert } from "../utils/assert";
10+
11+
export interface MessageBubbleButtonConfig {
12+
label: string;
13+
onPress: () => void;
14+
icon?: ReactNode;
15+
disabled?: boolean;
16+
active?: boolean;
17+
}
18+
19+
interface MessageBubbleProps {
20+
label?: ReactNode;
21+
rightLabel?: ReactNode;
22+
variant?: "assistant" | "user";
23+
message: DisplayedMessage;
24+
buttons?: MessageBubbleButtonConfig[];
25+
children: ReactNode;
26+
backgroundEffect?: ReactNode;
27+
}
28+
29+
export function MessageBubble(props: MessageBubbleProps): JSX.Element {
30+
const theme = useTheme();
31+
const [showJson, setShowJson] = useState(false);
32+
const variant = props.variant ?? "assistant";
33+
34+
const timestamp = useMemo(() => {
35+
if (typeof props.message.timestamp === "number") {
36+
return formatTimestamp(props.message.timestamp);
37+
}
38+
return null;
39+
}, [props.message.timestamp]);
40+
41+
const isLastPartOfMessage = useMemo(() => {
42+
if (
43+
"isLastPartOfMessage" in props.message &&
44+
props.message.isLastPartOfMessage &&
45+
"isPartial" in props.message &&
46+
props.message.isPartial === false
47+
) {
48+
return true;
49+
}
50+
return false;
51+
}, [props.message]);
52+
53+
const showMetaRow = variant === "user" || isLastPartOfMessage;
54+
55+
const metaButtons: MessageBubbleButtonConfig[] = useMemo(() => {
56+
const provided = props.buttons ?? [];
57+
return [
58+
...provided,
59+
{
60+
label: showJson ? "Hide JSON" : "Show JSON",
61+
onPress: () => setShowJson((prev) => !prev),
62+
active: showJson,
63+
},
64+
];
65+
}, [props.buttons, showJson]);
66+
67+
return (
68+
<View style={[styles.container, variant === "user" ? styles.alignUser : undefined]}>
69+
<Surface
70+
variant="plain"
71+
style={[
72+
styles.surface,
73+
variant === "user" ? styles.userSurface : styles.assistantSurface,
74+
{ borderColor: theme.colors.border },
75+
]}
76+
>
77+
{props.backgroundEffect}
78+
{showJson ? (
79+
<ScrollView style={styles.jsonScroll} showsVerticalScrollIndicator>
80+
<Text style={[styles.jsonText, { color: theme.colors.foregroundSecondary }]}>
81+
{JSON.stringify(props.message, null, 2)}
82+
</Text>
83+
</ScrollView>
84+
) : (
85+
props.children
86+
)}
87+
</Surface>
88+
89+
{showMetaRow && (
90+
<View
91+
style={[
92+
styles.metaRow,
93+
variant === "user" ? styles.metaRowUser : styles.metaRowAssistant,
94+
]}
95+
>
96+
<View style={styles.buttonsRow}>
97+
{metaButtons.map((button, index) => (
98+
<IconActionButton key={`${button.label}-${index}`} button={button} />
99+
))}
100+
</View>
101+
<View style={styles.metaRight}>
102+
{props.rightLabel}
103+
{props.label ? <View style={styles.labelContainer}>{props.label}</View> : null}
104+
{timestamp ? (
105+
<ThemedText variant="caption" style={styles.timestampText}>
106+
{timestamp}
107+
</ThemedText>
108+
) : null}
109+
</View>
110+
</View>
111+
)}
112+
</View>
113+
);
114+
}
115+
116+
interface IconActionButtonProps {
117+
button: MessageBubbleButtonConfig;
118+
}
119+
120+
function IconActionButton({ button }: IconActionButtonProps): JSX.Element {
121+
const theme = useTheme();
122+
assert(typeof button.onPress === "function", "MessageBubble button requires onPress handler");
123+
124+
const content = button.icon ? (
125+
button.icon
126+
) : (
127+
<ThemedText
128+
variant="caption"
129+
style={{
130+
fontWeight: button.active ? "700" : "500",
131+
color: button.active ? theme.colors.accent : theme.colors.foregroundSecondary,
132+
}}
133+
>
134+
{button.label}
135+
</ThemedText>
136+
);
137+
138+
return (
139+
<TouchableOpacity
140+
disabled={button.disabled}
141+
onPress={button.onPress}
142+
style={[
143+
styles.actionButton,
144+
button.active && { borderColor: theme.colors.accent },
145+
button.disabled && { opacity: 0.5 },
146+
]}
147+
accessibilityRole="button"
148+
accessibilityLabel={button.label}
149+
>
150+
{content}
151+
</TouchableOpacity>
152+
);
153+
}
154+
155+
const styles = StyleSheet.create({
156+
container: {
157+
width: "100%",
158+
marginBottom: 12,
159+
},
160+
alignUser: {
161+
alignItems: "flex-end",
162+
},
163+
surface: {
164+
padding: 12,
165+
borderRadius: 12,
166+
borderWidth: StyleSheet.hairlineWidth,
167+
},
168+
assistantSurface: {
169+
backgroundColor: "rgba(255, 255, 255, 0.04)",
170+
},
171+
userSurface: {
172+
backgroundColor: "rgba(255, 255, 255, 0.08)",
173+
},
174+
metaRow: {
175+
marginTop: 6,
176+
flexDirection: "row",
177+
alignItems: "center",
178+
justifyContent: "space-between",
179+
flexWrap: "wrap",
180+
gap: 8,
181+
},
182+
metaRowUser: {
183+
alignSelf: "flex-end",
184+
},
185+
metaRowAssistant: {
186+
alignSelf: "flex-start",
187+
},
188+
buttonsRow: {
189+
flexDirection: "row",
190+
alignItems: "center",
191+
gap: 6,
192+
},
193+
metaRight: {
194+
flexDirection: "row",
195+
alignItems: "center",
196+
gap: 8,
197+
flexWrap: "wrap",
198+
},
199+
actionButton: {
200+
borderWidth: StyleSheet.hairlineWidth,
201+
borderColor: "rgba(255, 255, 255, 0.1)",
202+
borderRadius: 6,
203+
paddingHorizontal: 8,
204+
paddingVertical: 4,
205+
},
206+
labelContainer: {
207+
flexDirection: "row",
208+
alignItems: "center",
209+
gap: 4,
210+
},
211+
timestampText: {
212+
opacity: 0.7,
213+
},
214+
jsonScroll: {
215+
maxHeight: 260,
216+
},
217+
jsonText: {
218+
fontFamily: "Courier",
219+
fontSize: 12,
220+
},
221+
});

0 commit comments

Comments
 (0)