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