182 lines
15 KiB
TypeScript
182 lines
15 KiB
TypeScript
import { Ionicons } from '@expo/vector-icons';
|
|
import { Image } from 'expo-image';
|
|
import * as ImagePicker from 'expo-image-picker';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { ActivityIndicator, FlatList, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
|
|
import { useChatConversation } from '@/hooks/use-chat-conversation';
|
|
import { chatApi } from '@/src/lib/chat-api';
|
|
import { prepareMobileChatImage } from '@/src/lib/chat-image';
|
|
import { reactionEmojis, type ChatAttachment, type MobileChatMessage, type MobileChatRoom } from '@/src/lib/chat';
|
|
|
|
export type ChatDraft = { text: string; image: ChatAttachment | null; clientId: string };
|
|
type Props = {
|
|
token: string; room: MobileChatRoom; enabled: boolean; draft: ChatDraft;
|
|
onDraft: (value: Pick<ChatDraft, 'text' | 'image'>) => void;
|
|
onSent: (clientId: string) => void; onHide: () => void; hiding: boolean;
|
|
};
|
|
|
|
function MessageImage({ token, room, message }: { token: string; room: MobileChatRoom; message: MobileChatMessage }) {
|
|
const [uri, setUri] = useState('');
|
|
const [error, setError] = useState(false);
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [retry, setRetry] = useState(0);
|
|
const roomRef = useRef(room);
|
|
roomRef.current = room;
|
|
useEffect(() => {
|
|
let active = true;
|
|
setUri(''); setError(false); setExpanded(false);
|
|
if (message.pending || message.failed) return;
|
|
chatApi.image(token, roomRef.current, message.id).then(value => { if (active) setUri(value); })
|
|
.catch(() => { if (active) setError(true); });
|
|
return () => { active = false; };
|
|
}, [message.failed, message.id, message.pending, retry, room.key, token]);
|
|
const image = message.image!;
|
|
const width = Math.min(260, image.width);
|
|
const height = Math.min(220, width * image.height / image.width);
|
|
if (message.pending || message.failed) return <Text style={styles.hint}>📷 {image.name}</Text>;
|
|
return <View style={{ marginVertical: 8 }}>
|
|
{uri ? <Pressable accessibilityRole="button" accessibilityLabel={`Bild öffnen: ${image.name}`} onPress={() => setExpanded(true)}>
|
|
<Image source={{ uri }} cachePolicy="none" contentFit="contain" style={{ width, height }} accessibilityLabel={image.name} />
|
|
</Pressable> : error ? <Pressable accessibilityRole="button" onPress={() => setRetry(value => value + 1)}>
|
|
<Text style={styles.link}>Bild erneut laden</Text>
|
|
</Pressable> : <ActivityIndicator accessibilityLabel="Bild wird geladen" />}
|
|
<Modal visible={expanded} animationType="fade" onRequestClose={() => setExpanded(false)}>
|
|
<View style={styles.imageModal}>
|
|
<Pressable accessibilityRole="button" onPress={() => setExpanded(false)} style={styles.closeImage}><Text style={styles.white}>Schließen</Text></Pressable>
|
|
<Image source={{ uri }} cachePolicy="none" contentFit="contain" style={styles.fullImage} accessibilityLabel={image.name} />
|
|
</View>
|
|
</Modal>
|
|
</View>;
|
|
}
|
|
|
|
export function ChatConversation({ token, room, enabled, draft, onDraft, onSent, onHide, hiding }: Props) {
|
|
const chat = useChatConversation(token, room, enabled);
|
|
const [membersOpen, setMembersOpen] = useState(false);
|
|
const [reactionMessage, setReactionMessage] = useState<MobileChatMessage | null>(null);
|
|
const [preparing, setPreparing] = useState(false);
|
|
const [imageError, setImageError] = useState('');
|
|
const list = useRef<FlatList<MobileChatMessage>>(null);
|
|
const alive = useRef(true);
|
|
const nearBottom = useRef(true);
|
|
const pickerGeneration = useRef(0);
|
|
const allowed = enabled && !!chat.detail?.canSend && !chat.loading;
|
|
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
|
useEffect(() => { if (!allowed) setReactionMessage(null); }, [allowed]);
|
|
|
|
const attach = async () => {
|
|
if (!allowed || preparing || chat.sending) return;
|
|
const version = ++pickerGeneration.current;
|
|
setPreparing(true); setImageError('');
|
|
try {
|
|
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 1 });
|
|
if (result.canceled || !alive.current || version !== pickerGeneration.current) return;
|
|
const image = await prepareMobileChatImage(result.assets[0]);
|
|
// The system photo picker can temporarily background the app. Keep the local
|
|
// attachment; sending still requires refreshed room permissions afterwards.
|
|
if (alive.current && version === pickerGeneration.current) onDraft({ text: draft.text, image });
|
|
} catch (error) {
|
|
if (alive.current && version === pickerGeneration.current) setImageError(error instanceof Error ? error.message : 'Bild konnte nicht geöffnet werden.');
|
|
} finally { if (alive.current && version === pickerGeneration.current) setPreparing(false); }
|
|
};
|
|
const send = async () => {
|
|
if (!allowed || preparing || (!draft.text.trim() && !draft.image)) return;
|
|
const sent = await chat.send(draft.text.trim(), draft.image, draft.clientId);
|
|
if (sent) { onSent(draft.clientId); nearBottom.current = true; list.current?.scrollToEnd({ animated: true }); }
|
|
};
|
|
const time = (message: MobileChatMessage) => {
|
|
const value = message.createdAt || message.timestamp;
|
|
return value ? new Date(value).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
|
|
};
|
|
const older = () => { nearBottom.current = false; void chat.older(); };
|
|
const renderMessage = ({ item }: { item: MobileChatMessage }) => {
|
|
const canReact = allowed && !chat.detail?.offline && !item.pending && !item.failed;
|
|
return <View style={[styles.messageRow, item.own && styles.ownRow]}>
|
|
<View style={[styles.bubble, item.own && styles.ownBubble]}>
|
|
<Text style={styles.author}>{item.own ? 'Du' : item.senderName || item.senderDisplayName || 'Benutzer'}</Text>
|
|
{item.image && <MessageImage key={`${chat.detail?.revision || 0}:${item.id}`} token={token} room={room} message={item} />}
|
|
{!!item.body && <Text selectable style={styles.body}>{item.body}</Text>}
|
|
<Text style={styles.time}>{time(item)}{item.pending ? ' · Wartet auf Zustellung' : item.failed ? ' · Zustellung fehlgeschlagen' : ''}</Text>
|
|
<View style={styles.reactions}>
|
|
{(chat.reactions[item.id] || []).map(reaction => <Pressable key={reaction.emoji} accessibilityRole="button"
|
|
accessibilityLabel={`${reaction.emoji}: ${reaction.count}${reaction.own ? ', eigene Reaktion entfernen' : ', reagieren'}`}
|
|
accessibilityState={{ selected: reaction.own, disabled: !canReact || chat.reacting }} disabled={!canReact || chat.reacting}
|
|
onPress={() => void chat.react(item, reaction.emoji)} style={[styles.reaction, reaction.own && styles.ownReaction]}>
|
|
<Text>{reaction.emoji} {reaction.count}</Text>
|
|
</Pressable>)}
|
|
{canReact && <Pressable accessibilityRole="button" accessibilityLabel="Reaktion hinzufügen" disabled={chat.reacting}
|
|
onPress={() => setReactionMessage(item)} style={styles.reaction}><Text>☺ +</Text></Pressable>}
|
|
</View>
|
|
</View>
|
|
</View>;
|
|
};
|
|
const scrollToEnd = useCallback(() => { if (nearBottom.current) list.current?.scrollToEnd({ animated: false }); }, []);
|
|
return <View style={styles.container}>
|
|
<View style={styles.header}>
|
|
<View style={styles.grow}><Text style={styles.title} numberOfLines={1}>{chat.detail?.name || room.name}</Text>
|
|
<Text style={styles.hint} numberOfLines={1}>{room.remote ? 'Extern · ' : ''}{room.ownerName || room.group}{chat.detail?.status === 'archived' ? ' · Archiviert' : ''}</Text></View>
|
|
<Pressable accessibilityRole="button" accessibilityLabel="Teilnehmer anzeigen" onPress={() => setMembersOpen(value => !value)} style={styles.iconButton}><Ionicons name="people-outline" size={23} /></Pressable>
|
|
<Pressable accessibilityRole="button" accessibilityLabel="Chat ausblenden" disabled={hiding} onPress={onHide} style={styles.iconButton}><Ionicons name="eye-off-outline" size={23} /></Pressable>
|
|
</View>
|
|
{membersOpen && <View style={styles.info}><Text style={styles.author}>Teilnehmer · {chat.members.length}</Text>
|
|
<Text>{chat.members.map(member => `${member.own ? 'Du' : member.name}${member.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') || 'Keine Teilnehmer verfügbar.'}</Text></View>}
|
|
{!!chat.error && <View style={styles.errorBox}><Text accessibilityRole="alert" style={styles.error}>{chat.error}</Text><Pressable onPress={chat.retry}><Text style={styles.link}>Erneut laden</Text></Pressable></View>}
|
|
{chat.detail?.offline && <Text style={styles.notice}>Die Partnerinstanz ist nicht erreichbar. Neue Nachrichten warten auf Zustellung.</Text>}
|
|
{!!chat.detail?.overview && <View style={styles.info}><Text style={styles.author}>Freigegebene Projektinformationen</Text>
|
|
{Object.entries(chat.detail.overview).filter(([, value]) => !!value).map(([key, value]) => <Text key={key}>{key === 'status' ? 'Status: ' : key === 'date' ? 'Termin: ' : ''}{value}</Text>)}
|
|
</View>}
|
|
{chat.loading ? <View style={styles.empty}><ActivityIndicator /><Text>Nachrichten werden geladen …</Text></View> :
|
|
<FlatList ref={list} data={chat.messages} keyExtractor={item => String(item.id)} renderItem={renderMessage}
|
|
style={styles.grow} contentContainerStyle={styles.messages} keyboardShouldPersistTaps="handled"
|
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
|
onScroll={event => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; nearBottom.current = contentSize.height - contentOffset.y - layoutMeasurement.height < 100; }}
|
|
scrollEventThrottle={100} onContentSizeChange={scrollToEnd}
|
|
ListHeaderComponent={chat.hasMore ? <Pressable disabled={chat.olderLoading} onPress={older} style={styles.loadOlder}><Text style={styles.link}>{chat.olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden'}</Text></Pressable> : null}
|
|
ListEmptyComponent={<Text style={styles.hint}>Noch keine Nachrichten in diesem Chat.</Text>} />}
|
|
{chat.detail && <View style={styles.composer}>
|
|
{!chat.detail.canSend && <Text style={styles.hint}>Dieser Chat ist nur lesbar.</Text>}
|
|
{!!imageError && <Text accessibilityRole="alert" style={styles.error}>{imageError}</Text>}
|
|
{draft.image && <View style={styles.attachment}><Image source={{ uri: draft.image.dataUrl }} cachePolicy="none" style={styles.thumbnail} contentFit="contain" />
|
|
<Text numberOfLines={1} style={styles.grow}>{draft.image.name}</Text>
|
|
<Pressable accessibilityLabel="Bild entfernen" disabled={chat.sending || preparing} onPress={() => onDraft({ text: draft.text, image: null })} style={styles.iconButton}><Ionicons name="close" size={23} /></Pressable></View>}
|
|
<View style={styles.composerRow}>
|
|
<Pressable accessibilityRole="button" accessibilityLabel="Bild anhängen" disabled={!allowed || preparing || chat.sending} onPress={() => void attach()} style={styles.iconButton}>
|
|
{preparing ? <ActivityIndicator /> : <Ionicons name="image-outline" size={25} color={allowed ? '#2f6f25' : '#9ca3af'} />}</Pressable>
|
|
<TextInput accessibilityLabel="Nachricht" placeholder={allowed ? 'Nachricht schreiben …' : 'Nur lesbar'} style={styles.input} multiline maxLength={10000}
|
|
editable={allowed && !preparing && !chat.sending} value={draft.text} onChangeText={text => onDraft({ text, image: draft.image })} />
|
|
<Pressable accessibilityRole="button" accessibilityLabel="Nachricht senden" disabled={!allowed || preparing || chat.sending || (!draft.text.trim() && !draft.image)} onPress={() => void send()}
|
|
style={[styles.send, (!allowed || preparing || chat.sending || (!draft.text.trim() && !draft.image)) && styles.disabled]}>
|
|
{chat.sending ? <ActivityIndicator color="#fff" /> : <Ionicons name="send" size={21} color="#fff" />}</Pressable>
|
|
</View>
|
|
</View>}
|
|
<Modal transparent visible={!!reactionMessage && allowed} animationType="fade" onRequestClose={() => setReactionMessage(null)}>
|
|
<Pressable style={styles.overlay} onPress={() => setReactionMessage(null)}><View style={styles.reactionPicker}>
|
|
<Text style={styles.title}>Reaktion auswählen</Text><ScrollView horizontal contentContainerStyle={styles.reactions}>
|
|
{reactionEmojis.map(emoji => <Pressable key={emoji} accessibilityRole="button" accessibilityLabel={`Mit ${emoji} reagieren`} style={styles.emoji}
|
|
onPress={() => { if (reactionMessage) void chat.react(reactionMessage, emoji); setReactionMessage(null); }}><Text style={{ fontSize: 28 }}>{emoji}</Text></Pressable>)}
|
|
</ScrollView></View></Pressable>
|
|
</Modal>
|
|
</View>;
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, minHeight: 0 }, grow: { flex: 1 },
|
|
header: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 8, backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e5e7eb' },
|
|
title: { fontSize: 16, fontWeight: '700', color: '#111827' }, hint: { color: '#6b7280', fontSize: 12 },
|
|
iconButton: { padding: 10, minHeight: 44, minWidth: 44, alignItems: 'center', justifyContent: 'center' },
|
|
messages: { padding: 12, gap: 12 }, messageRow: { flexDirection: 'row' }, ownRow: { justifyContent: 'flex-end' },
|
|
bubble: { maxWidth: '90%', padding: 12, borderRadius: 14, backgroundColor: '#fff', borderWidth: 1, borderColor: '#e5e7eb' },
|
|
ownBubble: { backgroundColor: '#eff9ea', borderColor: '#c7e6bb' }, author: { fontWeight: '700', fontSize: 12, color: '#374151', marginBottom: 4 },
|
|
body: { color: '#111827', fontSize: 15, lineHeight: 21 }, time: { color: '#6b7280', fontSize: 10, marginTop: 6, textAlign: 'right' },
|
|
reactions: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }, reaction: { paddingHorizontal: 9, paddingVertical: 5, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 16, backgroundColor: '#fff' },
|
|
ownReaction: { backgroundColor: '#dcfce7', borderColor: '#69c350' }, info: { padding: 12, gap: 3, backgroundColor: '#f0fdf4' },
|
|
notice: { padding: 12, backgroundColor: '#fffbeb', color: '#92400e', fontSize: 12 }, errorBox: { padding: 10, backgroundColor: '#fef2f2', gap: 5 }, error: { color: '#991b1b', fontSize: 12 },
|
|
link: { color: '#2f6f25', fontWeight: '600', paddingVertical: 5 }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 10 },
|
|
composer: { padding: 10, backgroundColor: '#fff', borderTopWidth: 1, borderColor: '#e5e7eb', gap: 8 }, composerRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 6 },
|
|
input: { flex: 1, minHeight: 44, maxHeight: 120, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 12, padding: 10, fontSize: 15, color: '#111827' },
|
|
send: { width: 44, height: 44, borderRadius: 12, backgroundColor: '#438b30', alignItems: 'center', justifyContent: 'center' }, disabled: { opacity: 0.4 },
|
|
attachment: { flexDirection: 'row', alignItems: 'center', gap: 8 }, thumbnail: { width: 52, height: 52 }, loadOlder: { alignItems: 'center', padding: 10 },
|
|
overlay: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: 'rgba(0,0,0,0.4)' }, reactionPicker: { backgroundColor: '#fff', borderRadius: 16, padding: 16 }, emoji: { padding: 9 },
|
|
imageModal: { flex: 1, backgroundColor: '#111827', paddingTop: 60, paddingBottom: 30 }, closeImage: { alignSelf: 'flex-end', padding: 20 }, white: { color: '#fff' }, fullImage: { flex: 1 },
|
|
});
|