import { useState, useEffect, useRef, useCallback } from 'react'; import { View, Text, FlatList, Pressable, TouchableOpacity, Modal, TextInput, ActivityIndicator, Platform, Alert, StyleSheet, ScrollView, Keyboard, KeyboardAvoidingView, } from 'react-native'; import { Image } from 'expo-image'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter, useLocalSearchParams } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; 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 { apiFetch } from '../lib/api'; import { supabase } from '../lib/supabase'; import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble'; import { ChatInput, type SendPayload } from '../components/chat/ChatInput'; import { useRoomRealtime } from '../hooks/useChatRealtime'; import { useColors } from '../lib/theme'; import { useOnlineUsers } from '../hooks/useOnlineUsers'; import { UserAvatar } from '../components/UserAvatar'; const GROUP_GAP_MS = 5 * 60 * 1000; type RoomDetail = { room: { id: string; name: string; description: string | null; isPublic: boolean; isDefault: boolean; joinMode: 'approval' | 'invite_only' | 'open'; avatarUrl: string | null; inviteCode: string | null; memberCount: number; createdBy: string; myRole: 'owner' | 'admin' | 'member' | null; isMember: boolean; }; members: Array<{ userId: string; role: string; nickname: string; avatar: string | null }>; messages: Array; hasMore: boolean; }; function decodeBase64(b64: string): Uint8Array { const binary = (globalThis as any).atob ? (globalThis as any).atob(b64) : Buffer.from(b64, 'base64').toString('binary'); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } export default function RoomScreen() { const { t } = useTranslation(); const router = useRouter(); const insets = useSafeAreaInsets(); const colors = useColors(); const styles = makeStyles(colors); const queryClient = useQueryClient(); const flatRef = useRef(null); const [myUserId, setMyUserId] = useState(); const { isOnline } = useOnlineUsers(); const { roomId } = useLocalSearchParams<{ roomId: string }>(); const [keyboardHeight, setKeyboardHeight] = useState(0); const [messages, setMessages] = useState([]); const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>( null, ); const [sending, setSending] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [joining, setJoining] = useState(false); const [joinStatus, setJoinStatus] = useState<'joined' | 'pending' | null>(null); useEffect(() => { supabase.auth.getSession().then(({ data }) => setMyUserId(data.session?.user.id)); }, []); useEffect(() => { const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; const show = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height)); const hide = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0)); return () => { show.remove(); hide.remove(); }; }, []); const { data, isLoading, refetch } = useQuery({ queryKey: ['chat-room', roomId], queryFn: async () => { const d = await apiFetch(`/api/chat/rooms/${roomId}`); const msgs: ChatMsg[] = d.messages.map((m: any) => ({ id: m.id, userId: m.userId, nickname: m.nickname, avatar: m.avatar, content: m.content, replyTo: m.replyTo ? { id: m.replyTo.id, userId: m.replyTo.userId, nickname: m.replyTo.nickname, content: m.replyTo.content, attachmentType: m.replyTo.attachmentType ?? null, } : null, attachmentUrl: m.attachmentUrl, attachmentType: m.attachmentType, attachmentName: m.attachmentName, likesCount: m.likesCount ?? 0, likedByMe: false, createdAt: m.createdAt, isOwn: m.isOwn, })); setMessages(msgs); return d; }, enabled: !!roomId, }); const room = data?.room; const members = data?.members ?? []; const isAdmin = room?.myRole === 'owner' || room?.myRole === 'admin'; // Realtime: neue Messages anderer User const onRoomInsert = useCallback( (row: any) => { const sender = members.find((m) => m.userId === row.user_id); setMessages((prev) => { if (prev.some((m) => m.id === row.id)) return prev; return [ ...prev, { id: row.id, userId: row.user_id, nickname: sender?.nickname ?? 'Anonym', avatar: sender?.avatar ?? null, content: row.content ?? '', replyTo: null, attachmentUrl: row.attachment_url ?? null, attachmentType: row.attachment_type ?? null, attachmentName: row.attachment_name ?? null, likesCount: 0, likedByMe: false, createdAt: row.created_at, isOwn: false, }, ]; }); }, [members], ); useRoomRealtime(roomId, myUserId, onRoomInsert, !!myUserId && !!room?.isMember); useEffect(() => { if (messages.length > 0) { requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true })); } }, [messages.length]); async function handleSend(payload: SendPayload) { if (sending || !room?.isMember) return; setSending(true); try { const newMsg = await apiFetch(`/api/chat/rooms/${roomId}/messages`, { method: 'POST', body: payload, }); setMessages((prev) => [ ...prev, { id: newMsg.id, userId: myUserId ?? '', nickname: 'Du', avatar: null, content: newMsg.content, replyTo: newMsg.replyTo ? { id: newMsg.replyTo.id, userId: newMsg.replyTo.userId, nickname: newMsg.replyTo.nickname, content: newMsg.replyTo.content, attachmentType: newMsg.replyTo.attachmentType ?? null, } : null, attachmentUrl: newMsg.attachmentUrl, attachmentType: newMsg.attachmentType, attachmentName: newMsg.attachmentName, likesCount: 0, likedByMe: false, createdAt: newMsg.createdAt, isOwn: true, }, ]); setReplyTo(null); } catch (err) { console.error('Room send failed:', err); } finally { setSending(false); } } async function toggleLike(msg: ChatMsg) { try { const { liked } = await apiFetch<{ liked: boolean }>('/api/chat/like', { method: 'POST', body: { messageId: msg.id, type: 'chat' }, }); setMessages((prev) => prev.map((m) => m.id === msg.id ? { ...m, likedByMe: liked, likesCount: m.likesCount + (liked ? 1 : -1) } : m, ), ); } catch {} } function startReply(msg: ChatMsg) { setReplyTo({ id: msg.id, nickname: msg.nickname ?? '?', content: msg.content?.slice(0, 100) || (msg.attachmentType === 'image' ? 'Bild' : ''), }); } function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean { if (!a || !b) return false; if (a.userId !== b.userId) return false; return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS; } async function handleJoin() { if (joining) return; setJoining(true); try { const res = await apiFetch<{ status: 'joined' | 'pending' | 'already_member' }>( `/api/chat/rooms/${roomId}/join`, { method: 'POST' }, ); if (res.status === 'pending') { setJoinStatus('pending'); } else { setJoinStatus('joined'); await refetch(); queryClient.invalidateQueries({ queryKey: ['chat-rooms'] }); } } catch (err: any) { Alert.alert('Fehler', err?.message ?? 'Beitritt fehlgeschlagen'); } finally { setJoining(false); } } async function handleAvatarUpload() { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) return; const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 0.8, allowsEditing: true, aspect: [1, 1], }); if (result.canceled || !result.assets[0]) return; const asset = result.assets[0]; try { const ext = asset.uri.split('.').pop()?.toLowerCase() || 'jpg'; const path = `room-avatars/${roomId}-${Date.now()}.${ext}`; const b64 = await FileSystem.readAsStringAsync(asset.uri, { encoding: FileSystem.EncodingType.Base64, }); const bytes = decodeBase64(b64); const { error: upErr } = await supabase.storage .from('rebreak-public') .upload(path, bytes, { contentType: asset.mimeType ?? `image/${ext}`, upsert: true, }); if (upErr) throw upErr; const { data: pub } = supabase.storage.from('rebreak-public').getPublicUrl(path); await apiFetch(`/api/chat/rooms/${roomId}`, { method: 'PATCH', body: { avatarUrl: pub.publicUrl }, }); await refetch(); queryClient.invalidateQueries({ queryKey: ['chat-rooms'] }); } catch (err: any) { Alert.alert('Fehler', err?.message ?? t('chat.upload_failed')); } } const initials = (room?.name ?? '?') .split(' ') .slice(0, 2) .map((w) => w[0]?.toUpperCase() ?? '') .join(''); return ( {/* Header */} router.back()} hitSlop={8} activeOpacity={0.7}> {room?.avatarUrl ? ( ) : ( {initials} )} {room?.name ?? '…'} {room && (() => { const onlineCount = members.filter((m) => isOnline(m.userId)).length; return ( {onlineCount > 0 ? t('chat.member_count_online', { n: room.memberCount, online: onlineCount }) : t('chat.member_count', { n: room.memberCount })} ); })()} setSettingsOpen(true)} hitSlop={8} activeOpacity={0.7}> {isLoading || !room ? ( ) : !room.isMember ? ( {room.name} {room.description && {room.description}} {t('chat.join_required')} {joinStatus === 'pending' ? ( {t('chat.join_pending')} ) : ( {joining ? ( ) : ( {t('chat.join')} )} )} ) : ( ( {}} /> )} keyExtractor={(m) => m.id} contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }} showsVerticalScrollIndicator={false} onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })} /> 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg }}> setReplyTo(null)} /> )} {/* Settings Modal */} setSettingsOpen(false)} room={room} members={members} isAdmin={isAdmin} onAvatarChange={handleAvatarUpload} onRefetch={refetch} roomId={roomId} /> ); } // ----- Settings Modal ----- function RoomSettingsModal({ visible, onClose, room, members, isAdmin, onAvatarChange, onRefetch, roomId, }: { visible: boolean; onClose: () => void; room: RoomDetail['room'] | undefined; members: RoomDetail['members']; isAdmin: boolean; onAvatarChange: () => void; onRefetch: () => void; roomId: string; }) { const { t } = useTranslation(); const colors = useColors(); const modal = makeModalStyles(colors); const [pendingRequests, setPendingRequests] = useState([]); const [loadingReqs, setLoadingReqs] = useState(false); useEffect(() => { if (!visible || !isAdmin) return; setLoadingReqs(true); apiFetch<{ requests: any[] }>(`/api/chat/rooms/${roomId}`, { method: 'PATCH', body: { action: 'list_requests' }, }) .then((d: any) => setPendingRequests(d?.requests ?? d ?? [])) .catch(() => setPendingRequests([])) .finally(() => setLoadingReqs(false)); }, [visible, isAdmin, roomId]); async function handleRequest(userId: string, action: 'approve' | 'reject') { try { await apiFetch(`/api/chat/rooms/${roomId}`, { method: 'PATCH', body: { action, userId }, }); setPendingRequests((prev) => prev.filter((r) => r.userId !== userId)); onRefetch(); } catch (err: any) { Alert.alert('Fehler', err?.message ?? 'Aktion fehlgeschlagen'); } } async function handlePromote(userId: string) { try { await apiFetch(`/api/chat/rooms/${roomId}`, { method: 'PATCH', body: { action: 'promote_admin', userId }, }); onRefetch(); } catch (err: any) { Alert.alert('Fehler', err?.message ?? 'Aktion fehlgeschlagen'); } } async function handleBan(userId: string) { Alert.alert('Bannen?', 'User wird aus dem Raum entfernt.', [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Bannen', style: 'destructive', onPress: async () => { try { await apiFetch(`/api/chat/rooms/${roomId}`, { method: 'PATCH', body: { action: 'ban', userId }, }); onRefetch(); } catch (err: any) { Alert.alert('Fehler', err?.message ?? 'Aktion fehlgeschlagen'); } }, }, ]); } async function handleLeave() { Alert.alert(t('chat.leave_room'), '', [ { text: 'Abbrechen', style: 'cancel' }, { text: t('chat.leave_room'), style: 'destructive', onPress: async () => { try { await apiFetch(`/api/chat/rooms/${roomId}/leave`, { method: 'POST' }); onClose(); } catch (err: any) { Alert.alert('Fehler', err?.message ?? 'Verlassen fehlgeschlagen'); } }, }, ]); } if (!room) return null; return ( {t('chat.settings')} {/* Avatar + Name */} {room.avatarUrl ? ( ) : ( )} {isAdmin && ( )} {room.name} {room.description && {room.description}} {/* Pending Requests */} {isAdmin && ( {t('chat.pending_request')} {loadingReqs ? ( ) : pendingRequests.length === 0 ? ( ) : ( pendingRequests.map((req) => ( {req.nickname ?? 'Anonym'} handleRequest(req.userId, 'approve')} activeOpacity={0.7} > {t('chat.approve')} handleRequest(req.userId, 'reject')} activeOpacity={0.7} > {t('chat.reject')} )) )} )} {/* Members */} {t('chat.members')} ({members.length}) {members.map((m) => ( {m.nickname} {m.role !== 'member' && ( {m.role} )} {isAdmin && m.role === 'member' && ( <> handlePromote(m.userId)} activeOpacity={0.7} > Admin handleBan(m.userId)} activeOpacity={0.7} > Ban )} ))} {/* Leave */} {!room.isDefault && ( {t('chat.leave_room')} )} ); } function makeStyles(colors: ReturnType) { return StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg }, header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, backgroundColor: colors.bg, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border, }, iconBtn: { padding: 8, alignItems: 'center', justifyContent: 'center', }, headerCenter: { flex: 1, flexDirection: 'row', alignItems: 'center', marginHorizontal: 8, }, headerAvatar: { width: 36, height: 36, borderRadius: 18, backgroundColor: colors.surfaceElevated, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', marginRight: 8, }, headerAvatarImg: { width: 36, height: 36 }, headerAvatarInitials: { fontSize: 12, fontFamily: 'Nunito_700Bold', color: colors.textMuted, }, headerName: { fontSize: 15, fontFamily: 'Nunito_700Bold', color: colors.text, }, headerSub: { fontSize: 11, fontFamily: 'Nunito_500Medium', color: colors.textMuted, marginTop: 1, }, loadingBox: { flex: 1, alignItems: 'center', justifyContent: 'center' }, joinBox: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32, }, joinTitle: { fontSize: 20, fontFamily: 'Nunito_700Bold', color: colors.text, marginTop: 14, }, joinDesc: { fontSize: 13, fontFamily: 'Nunito_500Medium', color: colors.textMuted, marginTop: 6, textAlign: 'center', }, joinHint: { fontSize: 12, fontFamily: 'Nunito_500Medium', color: colors.textMuted, marginTop: 18, textAlign: 'center', }, joinBtn: { marginTop: 16, backgroundColor: colors.brandOrange, paddingHorizontal: 32, paddingVertical: 12, borderRadius: 12, minWidth: 140, alignItems: 'center', }, joinBtnText: { color: '#fff', fontSize: 14, fontFamily: 'Nunito_700Bold', }, pendingBadge: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fef3c7', paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, marginTop: 16, }, pendingText: { color: '#92400e', fontSize: 12, fontFamily: 'Nunito_700Bold', marginLeft: 6, }, }); } function makeModalStyles(colors: ReturnType) { return StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 12, backgroundColor: colors.bg, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border, }, title: { fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }, section: { backgroundColor: colors.surface, borderRadius: 12, padding: 14, marginBottom: 12, }, sectionTitle: { fontSize: 12, fontFamily: 'Nunito_700Bold', color: colors.textMuted, textTransform: 'uppercase', marginBottom: 10, letterSpacing: 0.5, }, avatarWrap: { alignSelf: 'center', marginBottom: 10 }, avatar: { width: 80, height: 80, borderRadius: 40 }, avatarPlaceholder: { backgroundColor: colors.surfaceElevated, alignItems: 'center', justifyContent: 'center', }, avatarEdit: { position: 'absolute', right: -2, bottom: -2, width: 28, height: 28, borderRadius: 14, backgroundColor: colors.brandOrange, borderWidth: 3, borderColor: colors.bg, alignItems: 'center', justifyContent: 'center', }, roomName: { fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text, textAlign: 'center', }, roomDesc: { fontSize: 12, fontFamily: 'Nunito_500Medium', color: colors.textMuted, textAlign: 'center', marginTop: 4, }, memberRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border, }, memberName: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text }, memberRole: { fontSize: 11, color: colors.textMuted, marginTop: 1, textTransform: 'capitalize' }, actionBtn: { paddingHorizontal: 10, paddingVertical: 5, borderRadius: 6, }, actionText: { fontSize: 11, fontFamily: 'Nunito_700Bold' }, emptyText: { fontSize: 12, color: colors.textMuted }, leaveBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: '#fee2e2', paddingVertical: 12, borderRadius: 10, marginTop: 8, }, leaveText: { color: '#991b1b', fontSize: 13, fontFamily: 'Nunito_700Bold', marginLeft: 6, }, }); }