Versions: - expo: 53.0.0 → 54.0.34 - react-native: 0.79.6 → 0.81.5 - react: 19.0.0 → 19.1.0 - expo-router: 5.1.11 → 6.0.23 (major) - react-native-reanimated: 4.0.0 → 4.1.7 - react-native-worklets: 0.4.0 → 0.5.1 - react-native-screens: 4.11.1 → 4.16.0 - react-native-gesture-handler: 2.24.0 → 2.28.0 - @expo/metro-runtime: 5.0.5 → 6.1.2 - @types/react: → 19.2.14 - expo-av: 15.1.7 → 16.0.8 (still deprecated, last shipping in SDK 54) expo-file-system breaking change quick-fix: - New SDK 54 API is class-based (File/Directory/Paths). Legacy API `cacheDirectory` + `EncodingType` moved to `expo-file-system/legacy` sub-export. - 6 files updated to import from `expo-file-system/legacy` with TODO(sdk54) marker. Proper migration tracked as Task #14. Smoke-test: 0 TS errors, Metro bundles 2185 modules in 5.9s. Native binary still SDK 53 — Phase 5 prebuild --clean pending. Branch: upgrade/sdk-54, rollback tag: pre-sdk54-upgrade Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
334 lines
8.7 KiB
TypeScript
334 lines
8.7 KiB
TypeScript
import { useState, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
Pressable,
|
|
Image,
|
|
StyleSheet,
|
|
ActivityIndicator,
|
|
Platform,
|
|
Alert,
|
|
} from 'react-native';
|
|
import * as ImagePicker from 'expo-image-picker';
|
|
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
|
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { supabase } from '../../lib/supabase';
|
|
|
|
type ReplyTo = { id: string; nickname: string; content: string };
|
|
|
|
export type SendPayload = {
|
|
content: string;
|
|
replyToId?: string;
|
|
attachmentUrl?: string;
|
|
attachmentType?: string;
|
|
attachmentName?: string;
|
|
};
|
|
|
|
type Props = {
|
|
replyTo: ReplyTo | null;
|
|
sending?: boolean;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
onSend: (data: SendPayload) => void;
|
|
onCancelReply: () => void;
|
|
};
|
|
|
|
export function ChatInput({
|
|
replyTo,
|
|
sending,
|
|
placeholder,
|
|
disabled,
|
|
onSend,
|
|
onCancelReply,
|
|
}: Props) {
|
|
const { t } = useTranslation();
|
|
const [text, setText] = useState('');
|
|
const [attachment, setAttachment] = useState<{
|
|
uri: string;
|
|
name: string;
|
|
isImage: boolean;
|
|
} | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const inputRef = useRef<TextInput>(null);
|
|
|
|
const hasContent = text.trim().length > 0 || attachment !== null;
|
|
|
|
async function pickImage() {
|
|
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (!perm.granted) {
|
|
Alert.alert('Foto-Zugriff', 'Bitte Foto-Zugriff in den Einstellungen erlauben.');
|
|
return;
|
|
}
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
quality: 0.8,
|
|
});
|
|
if (!result.canceled && result.assets[0]?.uri) {
|
|
const a = result.assets[0];
|
|
setAttachment({
|
|
uri: a.uri,
|
|
name: a.fileName ?? `image-${Date.now()}.jpg`,
|
|
isImage: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
function clearAttachment() {
|
|
setAttachment(null);
|
|
}
|
|
|
|
async function uploadAttachment(): Promise<{
|
|
url: string;
|
|
type: string;
|
|
name: string;
|
|
} | null> {
|
|
if (!attachment) return null;
|
|
try {
|
|
setUploading(true);
|
|
const ext = attachment.name.split('.').pop() || 'jpg';
|
|
const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
|
const base64 = await FileSystem.readAsStringAsync(attachment.uri, {
|
|
encoding: FileSystem.EncodingType.Base64,
|
|
});
|
|
const arrayBuffer = decodeBase64(base64);
|
|
const { error } = await supabase.storage
|
|
.from('chat-attachments')
|
|
.upload(path, arrayBuffer, {
|
|
cacheControl: '3600',
|
|
upsert: false,
|
|
contentType: attachment.isImage ? 'image/jpeg' : 'application/octet-stream',
|
|
});
|
|
if (error) throw error;
|
|
const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path);
|
|
return {
|
|
url: data.publicUrl,
|
|
type: attachment.isImage ? 'image' : 'file',
|
|
name: attachment.name,
|
|
};
|
|
} catch (err: any) {
|
|
Alert.alert(t('chat.upload_failed'), err?.message ?? '');
|
|
return null;
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}
|
|
|
|
async function handleSend() {
|
|
const content = text.trim();
|
|
if (!content && !attachment) return;
|
|
|
|
let attachmentMeta: { url: string; type: string; name: string } | null = null;
|
|
if (attachment) {
|
|
attachmentMeta = await uploadAttachment();
|
|
if (!attachmentMeta) return;
|
|
}
|
|
|
|
onSend({
|
|
content,
|
|
replyToId: replyTo?.id,
|
|
attachmentUrl: attachmentMeta?.url,
|
|
attachmentType: attachmentMeta?.type,
|
|
attachmentName: attachmentMeta?.name,
|
|
});
|
|
setText('');
|
|
setAttachment(null);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{/* Reply preview */}
|
|
{replyTo && (
|
|
<View style={styles.replyBar}>
|
|
<Ionicons name="arrow-undo" size={14} color="#007AFF" style={{ marginRight: 6 }} />
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={styles.replyName} numberOfLines={1}>
|
|
{t('chat.reply_to')} {replyTo.nickname}
|
|
</Text>
|
|
<Text style={styles.replyContent} numberOfLines={1}>
|
|
{replyTo.content || '…'}
|
|
</Text>
|
|
</View>
|
|
<Pressable hitSlop={10} onPress={onCancelReply}>
|
|
<Ionicons name="close" size={16} color="#737373" />
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
|
|
{/* Attachment preview */}
|
|
{attachment && (
|
|
<View style={styles.attachBar}>
|
|
{attachment.isImage ? (
|
|
<Image source={{ uri: attachment.uri }} style={styles.attachImg} />
|
|
) : (
|
|
<View style={styles.attachFileIcon}>
|
|
<Ionicons name="document" size={18} color="#737373" />
|
|
</View>
|
|
)}
|
|
<Text style={styles.attachName} numberOfLines={1}>
|
|
{attachment.name}
|
|
</Text>
|
|
<Pressable hitSlop={10} onPress={clearAttachment}>
|
|
<Ionicons name="close" size={16} color="#737373" />
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
|
|
{/* Input row */}
|
|
<View style={styles.row}>
|
|
<Pressable
|
|
style={styles.iconBtn}
|
|
onPress={pickImage}
|
|
disabled={uploading || sending || disabled}
|
|
>
|
|
<Ionicons name="image-outline" size={22} color="#737373" />
|
|
</Pressable>
|
|
|
|
<View style={styles.inputWrap}>
|
|
<TextInput
|
|
ref={inputRef}
|
|
value={text}
|
|
onChangeText={setText}
|
|
placeholder={placeholder ?? t('chat.placeholder')}
|
|
placeholderTextColor="#a3a3a3"
|
|
multiline
|
|
maxLength={2000}
|
|
editable={!sending && !disabled}
|
|
style={styles.input}
|
|
/>
|
|
</View>
|
|
|
|
<Pressable
|
|
onPress={handleSend}
|
|
disabled={!hasContent || sending || uploading || disabled}
|
|
style={[
|
|
styles.sendBtn,
|
|
{ backgroundColor: hasContent ? '#007AFF' : '#e5e5e5' },
|
|
]}
|
|
>
|
|
{sending || uploading ? (
|
|
<ActivityIndicator size="small" color="#fff" />
|
|
) : (
|
|
<Ionicons name="send" size={16} color={hasContent ? '#fff' : '#a3a3a3'} />
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Base64 → Uint8Array (für Supabase Storage Upload)
|
|
function decodeBase64(base64: string): Uint8Array {
|
|
const binary =
|
|
typeof atob === 'function'
|
|
? atob(base64)
|
|
: Buffer.from(base64, 'base64').toString('binary');
|
|
const len = binary.length;
|
|
const bytes = new Uint8Array(len);
|
|
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
backgroundColor: '#ffffff',
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
borderTopColor: '#e5e5e5',
|
|
},
|
|
replyBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 8,
|
|
backgroundColor: '#eff6ff',
|
|
borderLeftWidth: 3,
|
|
borderLeftColor: '#007AFF',
|
|
marginHorizontal: 8,
|
|
marginTop: 6,
|
|
borderRadius: 8,
|
|
},
|
|
replyName: {
|
|
fontSize: 11,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: '#007AFF',
|
|
},
|
|
replyContent: {
|
|
fontSize: 11,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: '#525252',
|
|
marginTop: 1,
|
|
},
|
|
attachBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
backgroundColor: '#fafafa',
|
|
marginHorizontal: 8,
|
|
marginTop: 6,
|
|
borderRadius: 8,
|
|
},
|
|
attachImg: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 6,
|
|
marginRight: 8,
|
|
},
|
|
attachFileIcon: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 6,
|
|
backgroundColor: '#e5e5e5',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: 8,
|
|
},
|
|
attachName: {
|
|
flex: 1,
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: '#171717',
|
|
},
|
|
row: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-end',
|
|
paddingHorizontal: 8,
|
|
paddingTop: 8,
|
|
paddingBottom: 8,
|
|
},
|
|
iconBtn: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: 4,
|
|
},
|
|
inputWrap: {
|
|
flex: 1,
|
|
backgroundColor: '#f5f5f5',
|
|
borderRadius: 22,
|
|
paddingHorizontal: 14,
|
|
minHeight: 36,
|
|
maxHeight: 120,
|
|
justifyContent: 'center',
|
|
},
|
|
input: {
|
|
fontSize: 14,
|
|
lineHeight: 19,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: '#171717',
|
|
paddingVertical: Platform.OS === 'ios' ? 8 : 4,
|
|
},
|
|
sendBtn: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginLeft: 6,
|
|
},
|
|
});
|