feat: Phase 5 - in-app chat with real-time messages
- Chat list: auth-gated, shows all conversations for current user with other participant's avatar/name, last message preview, and relative time in Macedonian (пред X мин/ч/д) - Chat conversation: FlatList with inverted scroll, message bubbles aligned left (other) and right (self), colored backgrounds, timestamps, KeyboardAvoidingView send bar - Chat mutations: getOrCreate finds or creates a 1:1 chat, send creates message and updates lastMessageAt - Message queries: listByChat with participant auth check - Wired ad detail 'Започни разговор' button to create/open chat - ChatListItem component with avatar, preview, time, link to detail All UI text in Macedonian. TypeScript clean, Convex functions deployed.
This commit is contained in:
parent
c85ea09d4b
commit
723cf7e8e3
@ -1,20 +1,123 @@
|
|||||||
import { View, Text, ScrollView } from "react-native";
|
import { View, Text, ScrollView } from "react-native";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useQuery } from "convex/react";
|
||||||
|
import { api } from "../../../convex/_generated/api";
|
||||||
|
import { useAuth } from "../../_layout";
|
||||||
import { useTheme } from "../../../components/theme";
|
import { useTheme } from "../../../components/theme";
|
||||||
|
import { ChatListItem } from "../../../components/chat-list-item";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import { Loading } from "../../../components/ui/loading";
|
||||||
|
|
||||||
export default function ChatScreen() {
|
export default function ChatScreen() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const router = useRouter();
|
||||||
|
const { token, userId, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
const chats = useQuery(
|
||||||
|
api.chats.listByUser,
|
||||||
|
isAuthenticated && token ? { token } : "skip"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg }}>
|
<ScrollView
|
||||||
<View style={{ alignItems: "center", justifyContent: "center", paddingVertical: 60 }}>
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
<Text style={{ fontSize: 48, marginBottom: 16 }}>💬</Text>
|
contentContainerStyle={{ padding: theme.spacing.lg }}
|
||||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingVertical: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 48, marginBottom: 16 }}>
|
||||||
|
{"\uD83D\uDCAC"}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: theme.colors.text,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Чатови
|
Чатови
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Најавете се за да ги видите вашите разговори.
|
||||||
|
</Text>
|
||||||
|
<Button onPress={() => router.push("/(auth)/login")}>
|
||||||
|
Најави се
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chats === undefined) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loading />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chats.length === 0) {
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{ padding: theme.spacing.lg }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingVertical: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 48, marginBottom: 16 }}>
|
||||||
|
{"\uD83D\uDCAC"}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: theme.colors.text,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Немате разговори
|
||||||
|
</Text>
|
||||||
<Text style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
|
<Text style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||||
Започнете разговор со мајстор или клиент.
|
Започнете разговор со мајстор или клиент.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
style={{ backgroundColor: theme.colors.background }}
|
||||||
|
>
|
||||||
|
{chats.map((chat) => (
|
||||||
|
<ChatListItem key={chat._id} chat={chat} currentUserId={userId!} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { View, Text, ScrollView, Pressable, Alert } from "react-native";
|
import { View, Text, ScrollView, Pressable, Alert } from "react-native";
|
||||||
import { useLocalSearchParams, Stack, useRouter } from "expo-router";
|
import { useLocalSearchParams, Stack, useRouter } from "expo-router";
|
||||||
import { useQuery, useMutation } from "convex/react";
|
import { useQuery, useMutation } from "convex/react";
|
||||||
@ -24,6 +25,21 @@ export default function AdDetailScreen() {
|
|||||||
ad ? { id: ad.handymanId } : "skip"
|
ad ? { id: ad.handymanId } : "skip"
|
||||||
);
|
);
|
||||||
const deleteAd = useMutation(api.ads.remove);
|
const deleteAd = useMutation(api.ads.remove);
|
||||||
|
const startChat = useMutation(api.chats.getOrCreate);
|
||||||
|
const [startingChat, setStartingChat] = useState(false);
|
||||||
|
|
||||||
|
async function handleStartChat() {
|
||||||
|
if (!token || !ad) return;
|
||||||
|
setStartingChat(true);
|
||||||
|
try {
|
||||||
|
const chatId = await startChat({ token, partnerId: ad.handymanId });
|
||||||
|
router.push(`/chat/${chatId}` as any);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert("Грешка", e.message || "Неуспешно започнување разговор");
|
||||||
|
} finally {
|
||||||
|
setStartingChat(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!ad) {
|
if (!ad) {
|
||||||
return (
|
return (
|
||||||
@ -155,7 +171,7 @@ export default function AdDetailScreen() {
|
|||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
{!isOwner && isAuthenticated && (
|
{!isOwner && isAuthenticated && (
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
<Button onPress={() => {}}>Започни разговор</Button>
|
<Button loading={startingChat} onPress={handleStartChat}>Започни разговор</Button>
|
||||||
<Button variant="outline" onPress={() => {}}>Напиши оценка</Button>
|
<Button variant="outline" onPress={() => {}}>Напиши оценка</Button>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,22 +1,279 @@
|
|||||||
import { View, Text, ScrollView } from "react-native";
|
import { useState } from "react";
|
||||||
import { useLocalSearchParams, Stack } from "expo-router";
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
TextInput,
|
||||||
|
Pressable,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
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 { 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() {
|
export default function ChatDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const theme = useTheme();
|
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);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Разговор", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
<Stack.Screen
|
||||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
|
options={{
|
||||||
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
title: otherUserName,
|
||||||
<Text style={{ fontSize: 48, marginBottom: 16 }}>💬</Text>
|
headerShown: true,
|
||||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
|
headerBackButtonDisplayMode: "minimal",
|
||||||
Чат {id} — доаѓа во Фаза 5
|
}}
|
||||||
|
/>
|
||||||
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={messages}
|
||||||
|
keyExtractor={(item) => item._id}
|
||||||
|
inverted
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
102
components/chat-list-item.tsx
Normal file
102
components/chat-list-item.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { View, Text, Pressable } from "react-native";
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
import { useTheme } from "./theme";
|
||||||
|
import { Avatar } from "./ui/avatar";
|
||||||
|
|
||||||
|
interface ChatListItemProps {
|
||||||
|
chat: {
|
||||||
|
_id: string;
|
||||||
|
otherUser: {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
avatarId: string | null;
|
||||||
|
} | null;
|
||||||
|
lastMessage: {
|
||||||
|
content: string;
|
||||||
|
createdAt: number;
|
||||||
|
senderId: string;
|
||||||
|
} | null;
|
||||||
|
lastMessageAt: number;
|
||||||
|
};
|
||||||
|
currentUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(timestamp: number): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (minutes < 1) return "Сега";
|
||||||
|
if (minutes < 60) return `пред ${minutes} мин`;
|
||||||
|
if (hours < 24) return `пред ${hours} ч`;
|
||||||
|
if (days < 7) return `пред ${days} д`;
|
||||||
|
return new Date(timestamp).toLocaleDateString("mk-MK");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatListItem({ chat, currentUserId }: ChatListItemProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const isSelfMessage = chat.lastMessage?.senderId === currentUserId;
|
||||||
|
const previewText = chat.lastMessage
|
||||||
|
? (isSelfMessage ? "Вие: " : "") +
|
||||||
|
(chat.lastMessage.content.length > 40
|
||||||
|
? chat.lastMessage.content.slice(0, 40) + "\u2026"
|
||||||
|
: chat.lastMessage.content)
|
||||||
|
: "Нема пораки";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/chat/${chat._id}` as any} asChild>
|
||||||
|
<Pressable
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: theme.spacing.md,
|
||||||
|
paddingHorizontal: theme.spacing.lg,
|
||||||
|
gap: theme.spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.card,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
uri={chat.otherUser?.avatarId ?? null}
|
||||||
|
name={chat.otherUser?.name ?? "Непознат"}
|
||||||
|
size={50}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text, flex: 1 }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{chat.otherUser?.name ?? "Непознат"}
|
||||||
|
</Text>
|
||||||
|
{chat.lastMessage && (
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 12, color: theme.colors.textTertiary, marginLeft: 8 }}
|
||||||
|
>
|
||||||
|
{formatRelativeTime(chat.lastMessage.createdAt)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 14, color: theme.colors.textSecondary }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{previewText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { formatRelativeTime };
|
||||||
@ -2,10 +2,53 @@ import { query, mutation } from "./_generated/server";
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
export const listByUser = query({
|
export const listByUser = query({
|
||||||
args: { userId: v.id("users") },
|
args: { token: v.string() },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.db
|
||||||
|
.query("sessions")
|
||||||
|
.withIndex("by_token", (q) => q.eq("token", args.token))
|
||||||
|
.first();
|
||||||
|
if (!session) return [];
|
||||||
|
|
||||||
const allChats = await ctx.db.query("chats").order("desc").collect();
|
const allChats = await ctx.db.query("chats").order("desc").collect();
|
||||||
return allChats.filter((chat) => chat.participantIds.includes(args.userId));
|
const userChats = allChats.filter((chat) =>
|
||||||
|
chat.participantIds.includes(session.userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const enriched = await Promise.all(
|
||||||
|
userChats.map(async (chat) => {
|
||||||
|
const otherId = chat.participantIds.find(
|
||||||
|
(id) => id !== session.userId
|
||||||
|
)!;
|
||||||
|
const otherUser = await ctx.db.get(otherId);
|
||||||
|
const lastMessage = await ctx.db
|
||||||
|
.query("messages")
|
||||||
|
.withIndex("by_chat", (q) => q.eq("chatId", chat._id))
|
||||||
|
.order("desc")
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: chat._id,
|
||||||
|
otherUser: otherUser
|
||||||
|
? {
|
||||||
|
_id: otherUser._id,
|
||||||
|
name: otherUser.name,
|
||||||
|
avatarId: otherUser.avatarId ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
lastMessage: lastMessage
|
||||||
|
? {
|
||||||
|
content: lastMessage.content,
|
||||||
|
createdAt: lastMessage.createdAt,
|
||||||
|
senderId: lastMessage.senderId,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
lastMessageAt: chat.lastMessageAt,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return enriched.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -40,3 +83,39 @@ export const getOrCreate = mutation({
|
|||||||
return chatId;
|
return chatId;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getById = query({
|
||||||
|
args: { token: v.string(), chatId: v.id("chats") },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.db
|
||||||
|
.query("sessions")
|
||||||
|
.withIndex("by_token", (q) => q.eq("token", args.token))
|
||||||
|
.first();
|
||||||
|
if (!session) throw new Error("Неавторизиран");
|
||||||
|
|
||||||
|
const chat = await ctx.db.get(args.chatId);
|
||||||
|
if (!chat) throw new Error("Разговорот не е пронајден");
|
||||||
|
|
||||||
|
if (!chat.participantIds.includes(session.userId)) {
|
||||||
|
throw new Error("Немате пристап до овој разговор");
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherId = chat.participantIds.find(
|
||||||
|
(id) => id !== session.userId
|
||||||
|
)!;
|
||||||
|
const otherUser = await ctx.db.get(otherId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: chat._id,
|
||||||
|
participantIds: chat.participantIds,
|
||||||
|
otherUser: otherUser
|
||||||
|
? {
|
||||||
|
_id: otherUser._id,
|
||||||
|
name: otherUser.name,
|
||||||
|
avatarId: otherUser.avatarId ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
lastMessageAt: chat.lastMessageAt,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,9 +1,21 @@
|
|||||||
import { query } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
export const listByChat = query({
|
export const listByChat = query({
|
||||||
args: { chatId: v.id("chats") },
|
args: { token: v.string(), chatId: v.id("chats") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.db
|
||||||
|
.query("sessions")
|
||||||
|
.withIndex("by_token", (q) => q.eq("token", args.token))
|
||||||
|
.first();
|
||||||
|
if (!session) throw new Error("Неавторизиран");
|
||||||
|
|
||||||
|
const chat = await ctx.db.get(args.chatId);
|
||||||
|
if (!chat) throw new Error("Разговорот не е пронајден");
|
||||||
|
if (!chat.participantIds.includes(session.userId)) {
|
||||||
|
throw new Error("Немате пристап до овој разговор");
|
||||||
|
}
|
||||||
|
|
||||||
return await ctx.db
|
return await ctx.db
|
||||||
.query("messages")
|
.query("messages")
|
||||||
.withIndex("by_chat", (q) => q.eq("chatId", args.chatId))
|
.withIndex("by_chat", (q) => q.eq("chatId", args.chatId))
|
||||||
@ -11,3 +23,38 @@ export const listByChat = query({
|
|||||||
.collect();
|
.collect();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const send = mutation({
|
||||||
|
args: {
|
||||||
|
token: v.string(),
|
||||||
|
chatId: v.id("chats"),
|
||||||
|
content: v.string(),
|
||||||
|
imageId: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.db
|
||||||
|
.query("sessions")
|
||||||
|
.withIndex("by_token", (q) => q.eq("token", args.token))
|
||||||
|
.first();
|
||||||
|
if (!session) throw new Error("Неавторизиран");
|
||||||
|
|
||||||
|
const chat = await ctx.db.get(args.chatId);
|
||||||
|
if (!chat) throw new Error("Разговорот не е пронајден");
|
||||||
|
if (!chat.participantIds.includes(session.userId)) {
|
||||||
|
throw new Error("Немате пристап до овој разговор");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const messageId = await ctx.db.insert("messages", {
|
||||||
|
chatId: args.chatId,
|
||||||
|
senderId: session.userId,
|
||||||
|
content: args.content,
|
||||||
|
...(args.imageId ? { imageId: args.imageId } : {}),
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(args.chatId, { lastMessageAt: now });
|
||||||
|
|
||||||
|
return messageId;
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user