Files
carry-your-live/carry-your-live/src/components/FriendsModal.tsx
T

515 lines
16 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Modal,
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
FlatList,
ActivityIndicator,
Alert,
KeyboardAvoidingView,
} from 'react-native';
import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends';
interface FriendsModalProps {
visible: boolean;
onClose: () => void;
}
export function FriendsModal({ visible, onClose }: FriendsModalProps) {
const { theme } = useSettings();
const {
friends,
incoming,
outgoing,
loading,
sendRequest,
acceptRequest,
declineRequest,
removeFriend,
searchUsers,
} = useFriends();
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
const [searching, setSearching] = useState(false);
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>(
'friends'
);
const searchKey = `${selectedTab}:${searchQuery}`;
const [prevSearchKey, setPrevSearchKey] = useState(searchKey);
if (prevSearchKey !== searchKey && (selectedTab !== 'add' || searchQuery.length < 2)) {
setPrevSearchKey(searchKey);
setSearchResults([]);
}
useEffect(() => {
if (selectedTab === 'add' && searchQuery.length >= 2) {
const timeout = setTimeout(async () => {
setSearching(true);
try {
const results = await searchUsers(searchQuery);
setSearchResults(
results.filter(
r => !friends.some(f => f.id === r.id) && !outgoing.some(o => o.id === r.id)
)
);
} catch (err) {
console.error('Search failed:', err);
} finally {
setSearching(false);
}
}, 300);
return () => clearTimeout(timeout);
}
}, [searchQuery, selectedTab, friends, outgoing, searchUsers]);
const handleAccept = async (requestId: string) => {
try {
await acceptRequest(requestId);
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to accept request');
}
};
const handleDecline = async (requestId: string) => {
try {
await declineRequest(requestId);
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to decline request');
}
};
const handleRemove = async (friendId: string) => {
Alert.alert('Remove Friend', 'Are you sure you want to remove this friend?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
try {
await removeFriend(friendId);
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to remove friend');
}
},
},
]);
};
if (!visible) return null;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView
style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior="padding"
>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>Friends</Text>
<TouchableOpacity onPress={onClose} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
</TouchableOpacity>
</View>
<View style={styles.tabs}>
{(['friends', 'incoming', 'outgoing', 'add'] as const).map(tab => (
<TouchableOpacity
key={tab}
style={[
styles.tab,
selectedTab === tab ? styles.tabActive : {},
{ backgroundColor: selectedTab === tab ? theme.accent : 'transparent' },
]}
onPress={() => setSelectedTab(tab)}
>
<Text
style={[
styles.tabText,
{ color: selectedTab === tab ? theme.accentText : theme.textMuted },
]}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
{tab === 'friends' && friends.length > 0 && (
<Text style={[styles.badge, { color: theme.accentText }]}>
{friends.length}
</Text>
)}
{tab === 'incoming' && incoming.length > 0 && (
<Text style={[styles.badge, { color: theme.accentText }]}>
{incoming.length}
</Text>
)}
</Text>
</TouchableOpacity>
))}
</View>
{selectedTab === 'friends' && (
<View style={styles.content}>
{loading ? (
<View style={styles.loading}>
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : friends.length === 0 ? (
<View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text>
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>
{'Tap "Add" to find friends by username'}
</Text>
</View>
) : (
<FlatList
data={friends}
keyExtractor={f => f.id}
renderItem={({ item }) => (
<View style={[styles.friendRow, { borderBottomColor: theme.border }]}>
<Text style={[styles.friendName, { color: theme.text }]}>
{item.username}
</Text>
<TouchableOpacity
style={[
styles.removeBtn,
{ backgroundColor: theme.accentSoft, borderColor: theme.accent },
]}
onPress={() => handleRemove(item.id)}
>
<Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>
Remove
</Text>
</TouchableOpacity>
</View>
)}
/>
)}
</View>
)}
{selectedTab === 'incoming' && (
<View style={styles.content}>
{loading ? (
<View style={styles.loading}>
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : incoming.length === 0 ? (
<View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}>
No incoming requests
</Text>
</View>
) : (
<FlatList
data={incoming}
keyExtractor={r => r.requestId}
renderItem={({ item }) => (
<View style={[styles.requestRow, { borderBottomColor: theme.border }]}>
<Text style={[styles.friendName, { color: theme.text }]}>
{item.username}
</Text>
<View style={styles.requestActions}>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: theme.accent }]}
onPress={() => handleAccept(item.requestId)}
>
<Text style={[styles.actionBtnText, { color: theme.accentText }]}>
Accept
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.actionBtn,
{
backgroundColor: 'transparent',
borderColor: theme.borderStrong,
borderWidth: 1,
},
]}
onPress={() => handleDecline(item.requestId)}
>
<Text style={[styles.actionBtnText, { color: theme.textMuted }]}>
Decline
</Text>
</TouchableOpacity>
</View>
</View>
)}
/>
)}
</View>
)}
{selectedTab === 'outgoing' && (
<View style={styles.content}>
{loading ? (
<View style={styles.loading}>
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : outgoing.length === 0 ? (
<View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}>
No outgoing requests
</Text>
</View>
) : (
<FlatList
data={outgoing}
keyExtractor={r => r.requestId}
renderItem={({ item }) => (
<View style={[styles.requestRow, { borderBottomColor: theme.border }]}>
<Text style={[styles.friendName, { color: theme.textMuted }]}>
{item.username}
</Text>
<Text style={[styles.pendingText, { color: theme.textFaint }]}>Pending</Text>
<TouchableOpacity
style={[
styles.cancelBtn,
{
backgroundColor: 'transparent',
borderColor: theme.borderStrong,
borderWidth: 1,
},
]}
onPress={() => handleDecline(item.requestId)}
>
<Text style={[styles.cancelBtnText, { color: theme.textMuted }]}>
Cancel
</Text>
</TouchableOpacity>
</View>
)}
/>
)}
</View>
)}
{selectedTab === 'add' && (
<View style={styles.content}>
<View style={styles.searchContainer}>
<TextInput
style={[
styles.searchInput,
{
backgroundColor: theme.inputBg,
borderColor: theme.borderStrong,
color: theme.text,
},
]}
placeholder="Search by username"
placeholderTextColor={theme.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{searching ? (
<View style={styles.loading}>
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : searchQuery.length >= 2 && searchResults.length === 0 ? (
<View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No users found</Text>
</View>
) : searchQuery.length >= 2 && searchResults.length > 0 ? (
<FlatList
data={searchResults}
keyExtractor={u => u.id}
renderItem={({ item }) => (
<View style={[styles.friendRow, { borderBottomColor: theme.border }]}>
<Text style={[styles.friendName, { color: theme.text }]}>
{item.username}
</Text>
<TouchableOpacity
style={[styles.addBtn, { backgroundColor: theme.accent }]}
onPress={() =>
sendRequest(item.username)
.then(() => {
setSearchQuery('');
setSearchResults([]);
setSelectedTab('friends');
})
.catch(err =>
Alert.alert(
'Error',
err instanceof Error ? err.message : 'Failed to send request'
)
)
}
>
<Text style={[styles.addBtnText, { color: theme.accentText }]}>Add</Text>
</TouchableOpacity>
</View>
)}
/>
) : searchQuery.length < 2 ? (
<View style={styles.empty}>
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>
Type at least 2 characters to search
</Text>
</View>
) : null}
</View>
)}
</View>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
sheet: {
width: '100%',
maxWidth: 400,
borderRadius: 20,
overflow: 'hidden',
maxHeight: '85%',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 20,
borderBottomWidth: 1,
},
title: {
fontSize: 18,
fontWeight: '700',
},
closeText: {
fontSize: 22,
fontWeight: '300',
},
tabs: {
flexDirection: 'row',
borderBottomWidth: 1,
},
tab: {
flex: 1,
paddingVertical: 14,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 0,
},
tabActive: {
borderBottomWidth: 0,
},
tabText: {
fontSize: 14,
fontWeight: '600',
},
badge: {
fontSize: 11,
fontWeight: '700',
marginLeft: 6,
backgroundColor: 'rgba(255,255,255,0.2)',
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 8,
},
content: {
maxHeight: 400,
},
loading: {
padding: 40,
alignItems: 'center',
},
empty: {
padding: 40,
alignItems: 'center',
},
emptyText: {
fontSize: 16,
fontWeight: '500',
marginBottom: 4,
},
emptyHint: {
fontSize: 13,
},
friendRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 20,
},
requestRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 20,
},
friendName: {
fontSize: 16,
flex: 1,
},
pendingText: {
fontSize: 13,
fontWeight: '500',
marginRight: 12,
},
removeBtn: {
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
},
removeBtnText: {
fontSize: 13,
fontWeight: '600',
},
addBtn: {
paddingVertical: 6,
paddingHorizontal: 16,
borderRadius: 8,
},
addBtnText: {
fontSize: 13,
fontWeight: '600',
},
requestActions: {
flexDirection: 'row',
gap: 8,
},
actionBtn: {
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 8,
},
actionBtnText: {
fontSize: 13,
fontWeight: '600',
},
cancelBtn: {
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 8,
},
cancelBtnText: {
fontSize: 13,
fontWeight: '600',
},
searchContainer: {
padding: 20,
paddingBottom: 12,
},
searchInput: {
fontSize: 16,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
},
});