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,16 +1,107 @@
|
||||
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 { ChatListItem } from "../../../components/chat-list-item";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Loading } from "../../../components/ui/loading";
|
||||
|
||||
export default function ChatScreen() {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const { token, userId, isAuthenticated } = useAuth();
|
||||
|
||||
const chats = useQuery(
|
||||
api.chats.listByUser,
|
||||
isAuthenticated && token ? { token } : "skip"
|
||||
);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg }}>
|
||||
<View style={{ alignItems: "center", justifyContent: "center", paddingVertical: 60 }}>
|
||||
<Text style={{ fontSize: 48, marginBottom: 16 }}>💬</Text>
|
||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
|
||||
<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",
|
||||
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>
|
||||
@ -18,3 +109,15 @@ export default function ChatScreen() {
|
||||
</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 { useLocalSearchParams, Stack, useRouter } from "expo-router";
|
||||
import { useQuery, useMutation } from "convex/react";
|
||||
@ -24,6 +25,21 @@ export default function AdDetailScreen() {
|
||||
ad ? { id: ad.handymanId } : "skip"
|
||||
);
|
||||
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) {
|
||||
return (
|
||||
@ -155,7 +171,7 @@ export default function AdDetailScreen() {
|
||||
{/* Action buttons */}
|
||||
{!isOwner && isAuthenticated && (
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
<Button onPress={() => {}}>Започни разговор</Button>
|
||||
<Button loading={startingChat} onPress={handleStartChat}>Започни разговор</Button>
|
||||
<Button variant="outline" onPress={() => {}}>Напиши оценка</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@ -1,22 +1,279 @@
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { useLocalSearchParams, Stack } from "expo-router";
|
||||
import { useState } from "react";
|
||||
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 { 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);
|
||||
|
||||
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" }} />
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
|
||||
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
||||
<Text style={{ fontSize: 48, marginBottom: 16 }}>💬</Text>
|
||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
|
||||
Чат {id} — доаѓа во Фаза 5
|
||||
<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>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
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";
|
||||
|
||||
export const listByUser = query({
|
||||
args: { userId: v.id("users") },
|
||||
args: { token: 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) return [];
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
export const listByChat = query({
|
||||
args: { chatId: v.id("chats") },
|
||||
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("Немате пристап до овој разговор");
|
||||
}
|
||||
|
||||
return await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_chat", (q) => q.eq("chatId", args.chatId))
|
||||
@ -11,3 +23,38 @@ export const listByChat = query({
|
||||
.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