515 lines
16 KiB
TypeScript
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,
|
|
},
|
|
});
|