chahinebrini 2dcff6408c feat(chat): redesign chat list + conversation view
- RoomCard / chat.tsx DmItem: cleaner list rows (48px avatar, minHeight 68,
  consistent padding, time next to name, TouchableOpacity)
- ChatBubble: timestamp inline under content (no absolute-position hack),
  borderRadius 20, 28px avatar, lighter backdrop
- ChatInput: surface bg, hairline-bordered input pill, 38px send button

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:52:45 +02:00

344 lines
9.3 KiB
TypeScript

import { useState, useRef } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
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';
import { useColors } from '../../lib/theme';
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 colors = useColors();
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);
}
const styles = makeStyles(colors);
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>
<TouchableOpacity hitSlop={10} onPress={onCancelReply} activeOpacity={0.7}>
<Ionicons name="close" size={16} color="#737373" />
</TouchableOpacity>
</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>
<TouchableOpacity hitSlop={10} onPress={clearAttachment} activeOpacity={0.7}>
<Ionicons name="close" size={16} color="#737373" />
</TouchableOpacity>
</View>
)}
{/* Input row */}
<View style={styles.row}>
<TouchableOpacity
activeOpacity={0.7}
style={styles.iconBtn}
onPress={pickImage}
disabled={uploading || sending || disabled}
>
<Ionicons name="image-outline" size={22} color="#737373" />
</TouchableOpacity>
<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>
<TouchableOpacity
activeOpacity={0.7}
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'} />
)}
</TouchableOpacity>
</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;
}
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
container: {
backgroundColor: colors.surface,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
replyBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: colors.surface,
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: colors.textMuted,
marginTop: 1,
},
attachBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: colors.surface,
marginHorizontal: 8,
marginTop: 6,
borderRadius: 8,
},
attachImg: {
width: 36,
height: 36,
borderRadius: 6,
marginRight: 8,
},
attachFileIcon: {
width: 36,
height: 36,
borderRadius: 6,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
},
attachName: {
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
},
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: colors.bg,
borderRadius: 22,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.border,
paddingHorizontal: 14,
minHeight: 38,
maxHeight: 120,
justifyContent: 'center',
},
input: {
fontSize: 15,
lineHeight: 20,
fontFamily: 'Nunito_400Regular',
color: colors.text,
paddingVertical: Platform.OS === 'ios' ? 9 : 5,
},
sendBtn: {
width: 38,
height: 38,
borderRadius: 19,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 6,
},
});
}