Bug: 3 Stellen hatten `behavior={Platform.OS === 'ios' ? 'padding' : undefined}`.
Auf Android = `undefined` = KeyboardAvoidingView macht NICHTS → Input wird von
Tastatur verdeckt (chat-input, profile-edit-nickname, room-chat).
Fix: switch zu react-native-keyboard-controller's KeyboardAvoidingView mit
behavior='padding' für beide Plattformen. Funktioniert sauber cross-platform
weil KeyboardProvider schon im root-layout sitzt.
Affected Files:
- components/KeyboardAwareScreen.tsx (used by profile-edit + auth-screens)
- app/dm.tsx (DM chat)
- app/room.tsx (room chat)
lyra.tsx war bereits OK (`'height'` für Android — kein Fix nötig).
iOS-Verhalten unverändert.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
870 lines
27 KiB
TypeScript
870 lines
27 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
FlatList,
|
|
Pressable,
|
|
TouchableOpacity,
|
|
Image,
|
|
Modal,
|
|
TextInput,
|
|
ActivityIndicator,
|
|
Platform,
|
|
Alert,
|
|
StyleSheet,
|
|
ScrollView,
|
|
} from 'react-native';
|
|
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
|
|
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';
|
|
|
|
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<any>;
|
|
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<FlatList>(null);
|
|
const [myUserId, setMyUserId] = useState<string | undefined>();
|
|
|
|
const { roomId } = useLocalSearchParams<{ roomId: string }>();
|
|
|
|
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
|
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));
|
|
}, []);
|
|
|
|
const { data, isLoading, refetch } = useQuery<RoomDetail>({
|
|
queryKey: ['chat-room', roomId],
|
|
queryFn: async () => {
|
|
const d = await apiFetch<RoomDetail>(`/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<any>(`/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 (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<TouchableOpacity style={styles.iconBtn} onPress={() => router.back()} hitSlop={8} activeOpacity={0.7}>
|
|
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
|
</TouchableOpacity>
|
|
<View style={styles.headerCenter}>
|
|
<View style={styles.headerAvatar}>
|
|
{room?.avatarUrl ? (
|
|
<Image source={{ uri: room.avatarUrl }} style={styles.headerAvatarImg} />
|
|
) : (
|
|
<Text style={styles.headerAvatarInitials}>{initials}</Text>
|
|
)}
|
|
</View>
|
|
<View style={{ flexShrink: 1 }}>
|
|
<Text style={styles.headerName} numberOfLines={1}>
|
|
{room?.name ?? '…'}
|
|
</Text>
|
|
{room && (
|
|
<Text style={styles.headerSub} numberOfLines={1}>
|
|
{t('chat.member_count', { n: room.memberCount })}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
<TouchableOpacity style={styles.iconBtn} onPress={() => setSettingsOpen(true)} hitSlop={8} activeOpacity={0.7}>
|
|
<Ionicons name="ellipsis-horizontal" size={20} color={colors.text} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{isLoading || !room ? (
|
|
<View style={styles.loadingBox}>
|
|
<ActivityIndicator color={colors.brandOrange} />
|
|
</View>
|
|
) : !room.isMember ? (
|
|
<View style={styles.joinBox}>
|
|
<Ionicons name="people" size={48} color="#d4d4d4" />
|
|
<Text style={styles.joinTitle}>{room.name}</Text>
|
|
{room.description && <Text style={styles.joinDesc}>{room.description}</Text>}
|
|
<Text style={styles.joinHint}>{t('chat.join_required')}</Text>
|
|
{joinStatus === 'pending' ? (
|
|
<View style={styles.pendingBadge}>
|
|
<Ionicons name="time-outline" size={14} color="#92400e" />
|
|
<Text style={styles.pendingText}>{t('chat.join_pending')}</Text>
|
|
</View>
|
|
) : (
|
|
<TouchableOpacity
|
|
onPress={handleJoin}
|
|
disabled={joining}
|
|
activeOpacity={0.7}
|
|
style={[styles.joinBtn, joining && { opacity: 0.7 }]}
|
|
>
|
|
{joining ? (
|
|
<ActivityIndicator color="#fff" />
|
|
) : (
|
|
<Text style={styles.joinBtnText}>{t('chat.join')}</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
) : (
|
|
<KeyboardAvoidingView
|
|
style={{ flex: 1 }}
|
|
behavior="padding"
|
|
>
|
|
<FlatList
|
|
ref={flatRef}
|
|
data={messages}
|
|
style={{ flex: 1 }}
|
|
renderItem={({ item, index }) => (
|
|
<ChatBubble
|
|
msg={item}
|
|
showName
|
|
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
|
|
isLastInGroup={!sameAuthor(item, messages[index + 1])}
|
|
hideReadStatus
|
|
onReply={startReply}
|
|
onLike={toggleLike}
|
|
onOpenImage={() => {}}
|
|
/>
|
|
)}
|
|
keyExtractor={(m) => m.id}
|
|
contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }}
|
|
showsVerticalScrollIndicator={false}
|
|
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
|
|
/>
|
|
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
|
|
<ChatInput
|
|
replyTo={replyTo}
|
|
sending={sending}
|
|
onSend={handleSend}
|
|
onCancelReply={() => setReplyTo(null)}
|
|
/>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
)}
|
|
|
|
{/* Settings Modal */}
|
|
<RoomSettingsModal
|
|
visible={settingsOpen}
|
|
onClose={() => setSettingsOpen(false)}
|
|
room={room}
|
|
members={members}
|
|
isAdmin={isAdmin}
|
|
onAvatarChange={handleAvatarUpload}
|
|
onRefetch={refetch}
|
|
roomId={roomId}
|
|
/>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
// ----- 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<any[]>([]);
|
|
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 (
|
|
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
|
<SafeAreaView style={modal.container} edges={['top']}>
|
|
<View style={modal.header}>
|
|
<TouchableOpacity onPress={onClose} hitSlop={8} activeOpacity={0.7}>
|
|
<Ionicons name="close" size={24} color="#0a0a0a" />
|
|
</TouchableOpacity>
|
|
<Text style={modal.title}>{t('chat.settings')}</Text>
|
|
<View style={{ width: 24 }} />
|
|
</View>
|
|
|
|
<ScrollView contentContainerStyle={{ padding: 16, paddingBottom: 60 }}>
|
|
{/* Avatar + Name */}
|
|
<View style={modal.section}>
|
|
<TouchableOpacity
|
|
onPress={isAdmin ? onAvatarChange : undefined}
|
|
activeOpacity={0.7}
|
|
style={modal.avatarWrap}
|
|
>
|
|
{room.avatarUrl ? (
|
|
<Image source={{ uri: room.avatarUrl }} style={modal.avatar} />
|
|
) : (
|
|
<View style={[modal.avatar, modal.avatarPlaceholder]}>
|
|
<Ionicons name="people" size={32} color="#737373" />
|
|
</View>
|
|
)}
|
|
{isAdmin && (
|
|
<View style={modal.avatarEdit}>
|
|
<Ionicons name="camera" size={14} color="#fff" />
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
<Text style={modal.roomName}>{room.name}</Text>
|
|
{room.description && <Text style={modal.roomDesc}>{room.description}</Text>}
|
|
</View>
|
|
|
|
{/* Pending Requests */}
|
|
{isAdmin && (
|
|
<View style={modal.section}>
|
|
<Text style={modal.sectionTitle}>{t('chat.pending_request')}</Text>
|
|
{loadingReqs ? (
|
|
<ActivityIndicator color={colors.brandOrange} style={{ marginVertical: 12 }} />
|
|
) : pendingRequests.length === 0 ? (
|
|
<Text style={modal.emptyText}>—</Text>
|
|
) : (
|
|
pendingRequests.map((req) => (
|
|
<View key={req.userId} style={modal.memberRow}>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={modal.memberName}>{req.nickname ?? 'Anonym'}</Text>
|
|
</View>
|
|
<TouchableOpacity
|
|
style={[modal.actionBtn, { backgroundColor: '#dcfce7' }]}
|
|
onPress={() => handleRequest(req.userId, 'approve')}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={[modal.actionText, { color: '#166534' }]}>
|
|
{t('chat.approve')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[modal.actionBtn, { backgroundColor: '#fee2e2', marginLeft: 6 }]}
|
|
onPress={() => handleRequest(req.userId, 'reject')}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={[modal.actionText, { color: '#991b1b' }]}>
|
|
{t('chat.reject')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
))
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{/* Members */}
|
|
<View style={modal.section}>
|
|
<Text style={modal.sectionTitle}>
|
|
{t('chat.members')} ({members.length})
|
|
</Text>
|
|
{members.map((m) => (
|
|
<View key={m.userId} style={modal.memberRow}>
|
|
<View style={modal.memberAvatar}>
|
|
{m.avatar ? (
|
|
<Image source={{ uri: m.avatar }} style={modal.memberAvatarImg} />
|
|
) : (
|
|
<Text style={modal.memberInitials}>
|
|
{m.nickname.slice(0, 2).toUpperCase()}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={modal.memberName}>{m.nickname}</Text>
|
|
{m.role !== 'member' && (
|
|
<Text style={modal.memberRole}>{m.role}</Text>
|
|
)}
|
|
</View>
|
|
{isAdmin && m.role === 'member' && (
|
|
<>
|
|
<TouchableOpacity
|
|
style={[modal.actionBtn, { backgroundColor: '#fef3c7' }]}
|
|
onPress={() => handlePromote(m.userId)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={[modal.actionText, { color: '#92400e' }]}>Admin</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[modal.actionBtn, { backgroundColor: '#fee2e2', marginLeft: 6 }]}
|
|
onPress={() => handleBan(m.userId)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={[modal.actionText, { color: '#991b1b' }]}>Ban</Text>
|
|
</TouchableOpacity>
|
|
</>
|
|
)}
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
{/* Leave */}
|
|
{!room.isDefault && (
|
|
<TouchableOpacity style={modal.leaveBtn} onPress={handleLeave} activeOpacity={0.7}>
|
|
<Ionicons name="exit-outline" size={18} color="#991b1b" />
|
|
<Text style={modal.leaveText}>{t('chat.leave_room')}</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
|
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: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 12,
|
|
backgroundColor: colors.surfaceElevated,
|
|
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<typeof useColors>) {
|
|
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,
|
|
},
|
|
memberAvatar: {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 16,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
marginRight: 10,
|
|
},
|
|
memberAvatarImg: { width: 32, height: 32 },
|
|
memberInitials: {
|
|
fontSize: 11,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: colors.textMuted,
|
|
},
|
|
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,
|
|
},
|
|
});
|
|
}
|