Skip to content
86 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 style={[styles.bar, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: keyboard ? space.sm : Math.max(insets.bottom, space.sm) }]}>
56 <TextInput
57 value={text}
58 onChangeText={(next) => {
59 setText(next);
60 drafts.set(draftKey, next);
61 if (next.length > 0) onTyping();
62 }}
63 placeholder={placeholder}
64 placeholderTextColor={colors.faint}
65 onFocus={onFocus}
66 multiline
67 style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]}
68 />
69 <Pressable
70 accessibilityRole="button"
71 accessibilityLabel="Send"
72 onPress={send}
73 disabled={!ready}
74 style={[styles.send, { backgroundColor: ready ? colors.accent : colors.raised }]}
75 >
76 <ArrowUp size={20} color={ready ? colors.onAccent : colors.faint} strokeWidth={2.5} />
77 </Pressable>
78 </View>
79 );
80}
81
82const styles = StyleSheet.create({
83 bar: { flexDirection: "row", alignItems: "flex-end", gap: space.sm, paddingHorizontal: space.md, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth },
84 input: { flex: 1, minHeight: 40, maxHeight: 160, borderRadius: radius.lg, borderWidth: 1, paddingHorizontal: space.md, paddingTop: 10, paddingBottom: 10, fontSize: 15 },
85 send: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center" },
86});