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:
echo 2026-05-29 18:38:16 +02:00
parent c85ea09d4b
commit 723cf7e8e3
6 changed files with 629 additions and 25 deletions

View File

@ -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 (
<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>
</View>
</ScrollView>
);
}
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 }}>
Чатови
</Text>
<Text style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
Започнете разговор со мајстор или клиент.
</Text>
</View>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: theme.colors.background }}
>
{chats.map((chat) => (
<ChatListItem key={chat._id} chat={chat} currentUserId={userId!} />
))}
</ScrollView>
);
}

View File

@ -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>
)}

View File

@ -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",
}}
/>
<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: "Разговор", 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
</Text>
<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>
</ScrollView>
</KeyboardAvoidingView>
</>
);
}

View 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 };

View File

@ -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,
};
},
});

View File

@ -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;
},
});