Online-Status (Phase 1+):
- UserAvatar mit 4 Size-Variants (sm/md/lg/xl) + integrierter Online-Dot
- OnlinePresenceProvider: Supabase-Channel + Following-Filter
- ChatHeaderStatus: "Online" neutral / "vor X min" offline
- useLastSeen + Heartbeat (60s interval + AppState-background ping)
- Privatsphäre-Toggle in profile/index
Sheets:
- FormSheet Android-keyboard-fix (Dimensions.get('screen'), kein
useWindowDimensions-Kollaps), useKeyboardHandler statt manual
Keyboard.addListener, state-reset on re-open
- PostCommentsSheet same Pattern + close-after-submit + drag bis under
app-header
- ConnectMailSheet form-view refactor: scrollable, AES-Banner als
footnote, field-order email→pw→label, fixed 0.85 über alle Steps
Chat:
- DmChatBackground iOS klecks fix (G transform statt nested Svg)
- ChatInput Lyra-1:1 (keyboardWillShow, surfaceElevated bubble,
arrow-up send, attachment links)
- dm/room/chat headers + conversation-list nutzen UserAvatar
- Foreign-Profile "Nachricht"-Button öffnet richtige DM
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
341 lines
9.2 KiB
TypeScript
341 lines
9.2 KiB
TypeScript
import { useState, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
Image,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
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} resizeMode="cover" />
|
|
) : (
|
|
<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: '#007AFF', opacity: hasContent ? 1 : 0.4 },
|
|
]}
|
|
>
|
|
{sending || uploading ? (
|
|
<ActivityIndicator size="small" color="#fff" />
|
|
) : (
|
|
<Ionicons name="arrow-up" size={18} color="#fff" />
|
|
)}
|
|
</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.bg,
|
|
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',
|
|
gap: 8,
|
|
paddingHorizontal: 12,
|
|
paddingTop: 8,
|
|
paddingBottom: 8,
|
|
},
|
|
iconBtn: {
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: 19,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
inputWrap: {
|
|
flex: 1,
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderRadius: 22,
|
|
paddingVertical: 9,
|
|
paddingHorizontal: 16,
|
|
minHeight: 38,
|
|
maxHeight: 120,
|
|
justifyContent: 'center',
|
|
},
|
|
input: {
|
|
fontSize: 15,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.text,
|
|
padding: 0,
|
|
},
|
|
sendBtn: {
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: 19,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
});
|
|
}
|