- Error boundary with Macedonian fallback screen and retry button wrapping all navigation - Pull-to-refresh on all list/detail screens (home, explore, posts, chat, profile, ad detail, post detail) - Accessibility: labels on all interactive elements, roles on Pressable/Button, selectable text for user data - Deep linking: mojmajstor:// scheme configured in app.json, ad/[id] and chat/[id] routes work via Expo Router auto-routing - Pagination: cursor-based paginated queries for ads, posts, messages, chats, reviews with Load More buttons on frontend - Profile: shows customer posts with PostCard, loading/error states, create-post navigation - Auth error messages in Macedonian (Невалидни акредитиви, итн.) All UI text in Macedonian. TypeScript clean, Convex functions deployed.
295 lines
8.4 KiB
TypeScript
295 lines
8.4 KiB
TypeScript
import { useState, useCallback } from "react";
|
||
import {
|
||
View,
|
||
Text,
|
||
FlatList,
|
||
TextInput,
|
||
Pressable,
|
||
KeyboardAvoidingView,
|
||
Platform,
|
||
RefreshControl,
|
||
type ListRenderItemInfo,
|
||
} from "react-native";
|
||
import { useLocalSearchParams, Stack, useRouter } from "expo-router";
|
||
import { useQuery, useMutation } from "convex/react";
|
||
import { api } from "../../convex/_generated/api";
|
||
import { useAuth } from "../_layout";
|
||
import { useTheme } from "../../components/theme";
|
||
import { Button } from "../../components/ui/button";
|
||
import { Loading } from "../../components/ui/loading";
|
||
|
||
function formatMessageTime(timestamp: number): string {
|
||
return new Date(timestamp).toLocaleTimeString("mk-MK", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
export default function ChatDetailScreen() {
|
||
const { id } = useLocalSearchParams<{ id: string }>();
|
||
const theme = useTheme();
|
||
const router = useRouter();
|
||
const { token, userId, isAuthenticated } = useAuth();
|
||
|
||
const chat = useQuery(
|
||
api.chats.getById,
|
||
isAuthenticated && token && id ? { token, chatId: id as any } : "skip"
|
||
);
|
||
const messages = useQuery(
|
||
api.messages.listByChat,
|
||
isAuthenticated && token && id ? { token, chatId: id as any } : "skip"
|
||
);
|
||
|
||
const sendMessage = useMutation(api.messages.send);
|
||
const [text, setText] = useState("");
|
||
const [sending, setSending] = useState(false);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
|
||
const onRefresh = useCallback(() => {
|
||
setRefreshing(true);
|
||
setTimeout(() => setRefreshing(false), 800);
|
||
}, []);
|
||
|
||
async function handleSend() {
|
||
const content = text.trim();
|
||
if (!content || !token || !id) return;
|
||
setText("");
|
||
setSending(true);
|
||
try {
|
||
await sendMessage({ token, chatId: id as any, content });
|
||
} catch {
|
||
setText(content);
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}
|
||
|
||
if (!isAuthenticated) {
|
||
return (
|
||
<>
|
||
<Stack.Screen
|
||
options={{
|
||
title: "Разговор",
|
||
headerShown: true,
|
||
headerBackButtonDisplayMode: "minimal",
|
||
}}
|
||
/>
|
||
<View
|
||
style={{
|
||
flex: 1,
|
||
justifyContent: "center",
|
||
alignItems: "center",
|
||
padding: 24,
|
||
backgroundColor: theme.colors.background,
|
||
}}
|
||
>
|
||
<Text
|
||
style={{
|
||
fontSize: 16,
|
||
color: theme.colors.text,
|
||
marginBottom: 16,
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
Најавете се за да ги видите пораките.
|
||
</Text>
|
||
<Button onPress={() => router.push("/(auth)/login")}>
|
||
Најави се
|
||
</Button>
|
||
</View>
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (!chat || !messages) {
|
||
return (
|
||
<>
|
||
<Stack.Screen
|
||
options={{
|
||
title: "Разговор",
|
||
headerShown: true,
|
||
headerBackButtonDisplayMode: "minimal",
|
||
}}
|
||
/>
|
||
<View
|
||
style={{
|
||
flex: 1,
|
||
justifyContent: "center",
|
||
alignItems: "center",
|
||
backgroundColor: theme.colors.background,
|
||
}}
|
||
>
|
||
<Loading />
|
||
</View>
|
||
</>
|
||
);
|
||
}
|
||
|
||
const otherUserName = chat.otherUser?.name ?? "Непознат";
|
||
|
||
return (
|
||
<>
|
||
<Stack.Screen
|
||
options={{
|
||
title: otherUserName,
|
||
headerShown: true,
|
||
headerBackButtonDisplayMode: "minimal",
|
||
}}
|
||
/>
|
||
<KeyboardAvoidingView
|
||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||
style={{ flex: 1, backgroundColor: theme.colors.background }}
|
||
keyboardVerticalOffset={90}
|
||
>
|
||
{messages.length === 0 ? (
|
||
<View
|
||
style={{
|
||
flex: 1,
|
||
justifyContent: "center",
|
||
alignItems: "center",
|
||
padding: 24,
|
||
}}
|
||
>
|
||
<Text style={{ fontSize: 48, marginBottom: 16 }}>
|
||
{"\uD83D\uDCAC"}
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
fontSize: 16,
|
||
fontWeight: "600",
|
||
color: theme.colors.text,
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
Започнете разговор
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
color: theme.colors.textSecondary,
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
Испратете порака за да започнете разговор.
|
||
</Text>
|
||
</View>
|
||
) : (
|
||
<FlatList
|
||
data={messages}
|
||
keyExtractor={(item) => item._id}
|
||
inverted
|
||
refreshControl={
|
||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||
}
|
||
contentContainerStyle={{
|
||
paddingVertical: 16,
|
||
paddingHorizontal: 16,
|
||
}}
|
||
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
|
||
renderItem={({
|
||
item,
|
||
}: ListRenderItemInfo<typeof messages[number]>) => {
|
||
const isSelf = item.senderId === userId;
|
||
return (
|
||
<View
|
||
style={{
|
||
alignItems: isSelf ? "flex-end" : "flex-start",
|
||
}}
|
||
>
|
||
<View
|
||
style={{
|
||
maxWidth: "80%",
|
||
backgroundColor: isSelf
|
||
? theme.colors.primary
|
||
: theme.colors.surface,
|
||
borderRadius: 16,
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 10,
|
||
borderWidth: isSelf ? 0 : 1,
|
||
borderColor: theme.colors.border,
|
||
}}
|
||
>
|
||
<Text
|
||
style={{
|
||
color: isSelf ? "#FFFFFF" : theme.colors.text,
|
||
fontSize: 15,
|
||
lineHeight: 20,
|
||
}}
|
||
>
|
||
{item.content}
|
||
</Text>
|
||
</View>
|
||
<Text
|
||
style={{
|
||
fontSize: 11,
|
||
color: theme.colors.textTertiary,
|
||
marginTop: 4,
|
||
}}
|
||
>
|
||
{formatMessageTime(item.createdAt)}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}}
|
||
style={{ flex: 1 }}
|
||
/>
|
||
)}
|
||
<View
|
||
style={{
|
||
flexDirection: "row",
|
||
alignItems: "flex-end",
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 12,
|
||
borderTopWidth: 1,
|
||
borderTopColor: theme.colors.border,
|
||
backgroundColor: theme.colors.card,
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<TextInput
|
||
accessible
|
||
accessibilityLabel="Порака"
|
||
accessibilityRole="none"
|
||
value={text}
|
||
onChangeText={setText}
|
||
placeholder="Напишете порака..."
|
||
placeholderTextColor={theme.colors.textTertiary}
|
||
style={{
|
||
flex: 1,
|
||
backgroundColor: theme.colors.surface,
|
||
borderWidth: 1,
|
||
borderColor: theme.colors.border,
|
||
borderRadius: 20,
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 10,
|
||
fontSize: 16,
|
||
color: theme.colors.text,
|
||
maxHeight: 100,
|
||
}}
|
||
multiline
|
||
maxLength={1000}
|
||
/>
|
||
<Pressable
|
||
accessible
|
||
accessibilityLabel="Испрати порака"
|
||
accessibilityRole="button"
|
||
onPress={handleSend}
|
||
disabled={!text.trim() || sending}
|
||
style={{
|
||
backgroundColor: text.trim()
|
||
? theme.colors.primary
|
||
: theme.colors.border,
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
opacity: sending ? 0.5 : 1,
|
||
}}
|
||
>
|
||
<Text style={{ color: "#FFFFFF", fontSize: 18 }}>{"\u27A4"}</Text>
|
||
</Pressable>
|
||
</View>
|
||
</KeyboardAvoidingView>
|
||
</>
|
||
);
|
||
} |