mirror of
https://github.com/hanzoai/mobile.git
synced 2026-10-30 14:53:17 +00:00
130 lines
4.0 KiB
TypeScript
130 lines
4.0 KiB
TypeScript
import { Button, Text, TextArea, XStack, YStack } from '@hanzo/gui'
|
|
import { ArrowUp, Paperclip, Square, X } from '@hanzogui/lucide-icons-2'
|
|
import { useRouter } from 'expo-router'
|
|
import { useState, useSyncExternalStore } from 'react'
|
|
import { Image, Platform } from 'react-native'
|
|
|
|
import * as draft from './draft'
|
|
import type { Shot } from './draft'
|
|
|
|
// The composer: text, the attachment draft, send or stop. Enter sends; an
|
|
// open IME candidate never does — natively the keyboard commits the
|
|
// candidate before submitBehavior fires, and on web the composition flag is
|
|
// checked by hand. Attachments arrive through the /send flow and show here
|
|
// as removable thumbnails until the send consumes them.
|
|
export function Composer(props: {
|
|
streaming: boolean
|
|
onSend(text: string, shots: Shot[]): void
|
|
onStop(): void
|
|
}) {
|
|
const router = useRouter()
|
|
const [text, setText] = useState('')
|
|
const shots = useSyncExternalStore(draft.subscribe, draft.list, draft.list)
|
|
const ready = !props.streaming && (text.trim().length > 0 || shots.length > 0)
|
|
|
|
function send() {
|
|
if (!ready) return
|
|
const taken = draft.take()
|
|
const clean = text.trim()
|
|
setText('')
|
|
props.onSend(clean, taken)
|
|
}
|
|
|
|
function key(event: { nativeEvent: { key: string }; preventDefault?(): void }) {
|
|
if (Platform.OS !== 'web') return
|
|
const raw = event.nativeEvent as {
|
|
key: string
|
|
shiftKey?: boolean
|
|
isComposing?: boolean
|
|
keyCode?: number
|
|
}
|
|
if (raw.key !== 'Enter' || raw.shiftKey) return
|
|
// Mid-composition Enter commits the IME candidate; sending then would
|
|
// eat the word. 229 is the legacy composition keyCode.
|
|
if (raw.isComposing || raw.keyCode === 229) return
|
|
event.preventDefault?.()
|
|
send()
|
|
}
|
|
|
|
return (
|
|
<YStack borderTopWidth={1} borderColor="$borderColor" px="$3" pt="$2.5" pb="$2.5" gap="$2">
|
|
{shots.length > 0 ? (
|
|
<XStack gap="$2" flexWrap="wrap">
|
|
{shots.map((shot) => (
|
|
<YStack key={shot.id}>
|
|
<Image
|
|
source={{ uri: shot.uri }}
|
|
style={{ width: 56, height: 56, borderRadius: 10, backgroundColor: '#141414' }}
|
|
/>
|
|
<Button
|
|
position="absolute"
|
|
t={-6}
|
|
r={-6}
|
|
size="$1"
|
|
circular
|
|
bg="$color4"
|
|
icon={<X size={10} />}
|
|
onPress={() => draft.remove(shot.id)}
|
|
aria-label="Remove image"
|
|
/>
|
|
</YStack>
|
|
))}
|
|
</XStack>
|
|
) : null}
|
|
<XStack items="flex-end" gap="$2">
|
|
<Button
|
|
size="$3"
|
|
circular
|
|
chromeless
|
|
icon={<Paperclip size={18} color="$color10" />}
|
|
onPress={() => router.push('/send')}
|
|
aria-label="Attach"
|
|
/>
|
|
<TextArea
|
|
flex={1}
|
|
value={text}
|
|
onChangeText={setText}
|
|
placeholder="Message Hanzo"
|
|
placeholderTextColor="$color10"
|
|
bg="$color2"
|
|
borderWidth={1}
|
|
borderColor="$borderColor"
|
|
rounded="$6"
|
|
color="$color"
|
|
fontSize={15}
|
|
lineHeight={20}
|
|
px="$3.5"
|
|
py="$2.5"
|
|
minH={44}
|
|
maxH={132}
|
|
multiline
|
|
returnKeyType="send"
|
|
submitBehavior="submit"
|
|
onSubmitEditing={send}
|
|
onKeyPress={key}
|
|
/>
|
|
{props.streaming ? (
|
|
<Button
|
|
size="$3"
|
|
circular
|
|
bg="$color4"
|
|
icon={<Square size={13} color="$color" />}
|
|
onPress={props.onStop}
|
|
aria-label="Stop"
|
|
/>
|
|
) : (
|
|
<Button
|
|
size="$3"
|
|
circular
|
|
bg={ready ? '$color' : '$color3'}
|
|
disabled={!ready}
|
|
icon={<ArrowUp size={18} color={ready ? '$background' : '$color10'} />}
|
|
onPress={send}
|
|
aria-label="Send"
|
|
/>
|
|
)}
|
|
</XStack>
|
|
</YStack>
|
|
)
|
|
}
|