diff --git a/app/(tabs)/(chat)/index.tsx b/app/(tabs)/(chat)/index.tsx index 5f94142..f7ff0b6 100644 --- a/app/(tabs)/(chat)/index.tsx +++ b/app/(tabs)/(chat)/index.tsx @@ -1,20 +1,123 @@ 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 ( + + + + {"\uD83D\uDCAC"} + + + Чатови + + + Најавете се за да ги видите вашите разговори. + + + + + ); + } + + if (chats === undefined) { + return ( + + + + ); + } + + if (chats.length === 0) { + return ( + + + + {"\uD83D\uDCAC"} + + + Немате разговори + + + Започнете разговор со мајстор или клиент. + + + + ); + } return ( - - - 💬 - - Чатови - - - Започнете разговор со мајстор или клиент. - - + + {chats.map((chat) => ( + + ))} ); } \ No newline at end of file diff --git a/app/ad/[id].tsx b/app/ad/[id].tsx index 6a4d0d5..4984b07 100644 --- a/app/ad/[id].tsx +++ b/app/ad/[id].tsx @@ -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 && ( - + )} diff --git a/app/chat/[id].tsx b/app/chat/[id].tsx index 87eb2b8..8c93ee9 100644 --- a/app/chat/[id].tsx +++ b/app/chat/[id].tsx @@ -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 ( + <> + + + + Најавете се за да ги видите пораките. + + + + + ); + } + + if (!chat || !messages) { + return ( + <> + + + + + + ); + } + + const otherUserName = chat.otherUser?.name ?? "Непознат"; return ( <> - - - - 💬 - - Чат {id} — доаѓа во Фаза 5 - + + + {messages.length === 0 ? ( + + + {"\uD83D\uDCAC"} + + + Започнете разговор + + + Испратете порака за да започнете разговор. + + + ) : ( + item._id} + inverted + contentContainerStyle={{ + paddingVertical: 16, + paddingHorizontal: 16, + }} + ItemSeparatorComponent={() => } + renderItem={({ + item, + }: ListRenderItemInfo) => { + const isSelf = item.senderId === userId; + return ( + + + + {item.content} + + + + {formatMessageTime(item.createdAt)} + + + ); + }} + style={{ flex: 1 }} + /> + )} + + + + {"\u27A4"} + - + ); } \ No newline at end of file diff --git a/components/chat-list-item.tsx b/components/chat-list-item.tsx new file mode 100644 index 0000000..f4382a0 --- /dev/null +++ b/components/chat-list-item.tsx @@ -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 ( + + + + + + + {chat.otherUser?.name ?? "Непознат"} + + {chat.lastMessage && ( + + {formatRelativeTime(chat.lastMessage.createdAt)} + + )} + + + {previewText} + + + + + ); +} + +export { formatRelativeTime }; \ No newline at end of file diff --git a/convex/chats.ts b/convex/chats.ts index 397d97e..dcb16c3 100644 --- a/convex/chats.ts +++ b/convex/chats.ts @@ -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); }, }); @@ -39,4 +82,40 @@ 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, + }; + }, }); \ No newline at end of file diff --git a/convex/messages.ts b/convex/messages.ts index d7125c7..35f30cf 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -1,13 +1,60 @@ -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)) .order("desc") .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; + }, }); \ No newline at end of file