chahinebrini 59a80627d8 chore(deps): Expo SDK 54 / RN 0.81 — Phase 1 core upgrade (JS-side)
Versions:
- expo: 53.0.0 → 54.0.34
- react-native: 0.79.6 → 0.81.5
- react: 19.0.0 → 19.1.0
- expo-router: 5.1.11 → 6.0.23 (major)
- react-native-reanimated: 4.0.0 → 4.1.7
- react-native-worklets: 0.4.0 → 0.5.1
- react-native-screens: 4.11.1 → 4.16.0
- react-native-gesture-handler: 2.24.0 → 2.28.0
- @expo/metro-runtime: 5.0.5 → 6.1.2
- @types/react: → 19.2.14
- expo-av: 15.1.7 → 16.0.8 (still deprecated, last shipping in SDK 54)

expo-file-system breaking change quick-fix:
- New SDK 54 API is class-based (File/Directory/Paths). Legacy API `cacheDirectory`
  + `EncodingType` moved to `expo-file-system/legacy` sub-export.
- 6 files updated to import from `expo-file-system/legacy` with TODO(sdk54)
  marker. Proper migration tracked as Task #14.

Smoke-test: 0 TS errors, Metro bundles 2185 modules in 5.9s.

Native binary still SDK 53 — Phase 5 prebuild --clean pending.
Branch: upgrade/sdk-54, rollback tag: pre-sdk54-upgrade

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:46:09 +02:00

858 lines
26 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import {
View,
Text,
FlatList,
Pressable,
Image,
Modal,
TextInput,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Alert,
StyleSheet,
ScrollView,
} from 'react-native';
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 { colors } 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 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}>
<Pressable style={styles.iconBtn} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
</Pressable>
<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>
<Pressable style={styles.iconBtn} onPress={() => setSettingsOpen(true)} hitSlop={8}>
<Ionicons name="ellipsis-horizontal" size={20} color="#0a0a0a" />
</Pressable>
</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>
) : (
<Pressable
onPress={handleJoin}
disabled={joining}
style={({ pressed }) => [
styles.joinBtn,
{ opacity: pressed || joining ? 0.7 : 1 },
]}
>
{joining ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.joinBtnText}>{t('chat.join')}</Text>
)}
</Pressable>
)}
</View>
) : (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<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 [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}>
<Pressable onPress={onClose} hitSlop={8}>
<Ionicons name="close" size={24} color="#0a0a0a" />
</Pressable>
<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}>
<Pressable
onPress={isAdmin ? onAvatarChange : undefined}
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>
)}
</Pressable>
<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>
<Pressable
style={[modal.actionBtn, { backgroundColor: '#dcfce7' }]}
onPress={() => handleRequest(req.userId, 'approve')}
>
<Text style={[modal.actionText, { color: '#166534' }]}>
{t('chat.approve')}
</Text>
</Pressable>
<Pressable
style={[modal.actionBtn, { backgroundColor: '#fee2e2', marginLeft: 6 }]}
onPress={() => handleRequest(req.userId, 'reject')}
>
<Text style={[modal.actionText, { color: '#991b1b' }]}>
{t('chat.reject')}
</Text>
</Pressable>
</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' && (
<>
<Pressable
style={[modal.actionBtn, { backgroundColor: '#fef3c7' }]}
onPress={() => handlePromote(m.userId)}
>
<Text style={[modal.actionText, { color: '#92400e' }]}>Admin</Text>
</Pressable>
<Pressable
style={[modal.actionBtn, { backgroundColor: '#fee2e2', marginLeft: 6 }]}
onPress={() => handleBan(m.userId)}
>
<Text style={[modal.actionText, { color: '#991b1b' }]}>Ban</Text>
</Pressable>
</>
)}
</View>
))}
</View>
{/* Leave */}
{!room.isDefault && (
<Pressable style={modal.leaveBtn} onPress={handleLeave}>
<Ionicons name="exit-outline" size={18} color="#991b1b" />
<Text style={modal.leaveText}>{t('chat.leave_room')}</Text>
</Pressable>
)}
</ScrollView>
</SafeAreaView>
</Modal>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fafafa' },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e5e5',
},
iconBtn: {
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: '#f5f5f5',
alignItems: 'center',
justifyContent: 'center',
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 8,
},
headerAvatar: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#e5e5e5',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 8,
},
headerAvatarImg: { width: 36, height: 36 },
headerAvatarInitials: {
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: '#737373',
},
headerName: {
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#171717',
},
headerSub: {
fontSize: 11,
fontFamily: 'Nunito_500Medium',
color: '#737373',
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: '#171717',
marginTop: 14,
},
joinDesc: {
fontSize: 13,
fontFamily: 'Nunito_500Medium',
color: '#737373',
marginTop: 6,
textAlign: 'center',
},
joinHint: {
fontSize: 12,
fontFamily: 'Nunito_500Medium',
color: '#a3a3a3',
marginTop: 18,
textAlign: 'center',
},
joinBtn: {
marginTop: 16,
backgroundColor: '#007AFF',
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,
},
});
const modal = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fafafa' },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e5e5',
},
title: { fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#171717' },
section: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 14,
marginBottom: 12,
},
sectionTitle: {
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: '#737373',
textTransform: 'uppercase',
marginBottom: 10,
letterSpacing: 0.5,
},
avatarWrap: { alignSelf: 'center', marginBottom: 10 },
avatar: { width: 80, height: 80, borderRadius: 40 },
avatarPlaceholder: {
backgroundColor: '#e5e5e5',
alignItems: 'center',
justifyContent: 'center',
},
avatarEdit: {
position: 'absolute',
right: -2,
bottom: -2,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#007AFF',
borderWidth: 3,
borderColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
roomName: {
fontSize: 17,
fontFamily: 'Nunito_700Bold',
color: '#171717',
textAlign: 'center',
},
roomDesc: {
fontSize: 12,
fontFamily: 'Nunito_500Medium',
color: '#737373',
textAlign: 'center',
marginTop: 4,
},
memberRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#f5f5f5',
},
memberAvatar: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#e5e5e5',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 10,
},
memberAvatarImg: { width: 32, height: 32 },
memberInitials: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
},
memberName: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#171717' },
memberRole: { fontSize: 11, color: '#a3a3a3', marginTop: 1, textTransform: 'capitalize' },
actionBtn: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
},
actionText: { fontSize: 11, fontFamily: 'Nunito_700Bold' },
emptyText: { fontSize: 12, color: '#a3a3a3' },
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,
},
});