Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

108 linesCodeBlameRaw
1import * as Haptics from "expo-haptics";
2import { ArrowUp } from "lucide-react-native";
3import { useEffect, useState } from "react";
4import { Keyboard, Pressable, StyleSheet, TextInput, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";
6
7import { radius, space, useColors } from "@/theme";
8
9/** What was typed and not sent, per conversation, while the app runs. */
10const drafts = new Map<string, string>();
11
12/**
13 * The box at the bottom of a conversation. Enter makes a new line, as on
14 * a phone; the arrow sends. Typing tells the others (at most every 3 s).
15 */
16export function Composer({
17 draftKey,
18 placeholder,
19 onSend,
20 onTyping,
21 onFocus,
22}: {
23 draftKey: string;
24 placeholder: string;
25 onSend: (body: string) => void;
26 onTyping: () => void;
27 /** The box was tapped into: the conversation goes to its newest message. */
28 onFocus?: () => void;
29}) {
30 const colors = useColors();
31 const insets = useSafeAreaInsets();
32 const [text, setText] = useState(() => drafts.get(draftKey) ?? "");
33 const ready = text.trim().length > 0;
34 // With the keyboard up the box sits on it; the home indicator's room is only for when it is down.
35 const [keyboard, setKeyboard] = useState(false);
36 useEffect(() => {
37 const shown = Keyboard.addListener("keyboardDidShow", () => setKeyboard(true));
38 const hidden = Keyboard.addListener("keyboardDidHide", () => setKeyboard(false));
39 return () => {
40 shown.remove();
41 hidden.remove();
42 };
43 }, []);
44
45 const send = () => {
46 const body = text.trim();
47 if (!body) return;
48 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined);
49 onSend(body);
50 setText("");
51 drafts.delete(draftKey);
52 };
53
54 return (
55 <View
56 style={[
57 styles.bar,
58 { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: keyboard ? space.sm : Math.max(insets.bottom, space.sm) },
59 ]}
60 >
61 <TextInput
62 value={text}
63 onChangeText={(next) => {
64 setText(next);
65 drafts.set(draftKey, next);
66 if (next.length > 0) onTyping();
67 }}
68 placeholder={placeholder}
69 placeholderTextColor={colors.faint}
70 onFocus={onFocus}
71 multiline
72 style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]}
73 />
74 <Pressable
75 accessibilityRole="button"
76 accessibilityLabel="Send"
77 onPress={send}
78 disabled={!ready}
79 style={[styles.send, { backgroundColor: ready ? colors.accent : colors.raised }]}
80 >
81 <ArrowUp size={20} color={ready ? colors.onAccent : colors.faint} strokeWidth={2.5} />
82 </Pressable>
83 </View>
84 );
85}
86
87const styles = StyleSheet.create({
88 bar: {
89 flexDirection: "row",
90 alignItems: "flex-end",
91 gap: space.sm,
92 paddingHorizontal: space.md,
93 paddingTop: space.sm,
94 borderTopWidth: StyleSheet.hairlineWidth,
95 },
96 input: {
97 flex: 1,
98 minHeight: 40,
99 maxHeight: 160,
100 borderRadius: radius.lg,
101 borderWidth: 1,
102 paddingHorizontal: space.md,
103 paddingTop: 10,
104 paddingBottom: 10,
105 fontSize: 15,
106 },
107 send: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center" },
108});