chahinebrini 5c539f8937 feat(presence,sheets,chat): tester-build polish bundle
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>
2026-05-18 08:06:47 +02:00

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',
},
});
}