- Error boundary with Macedonian fallback screen and retry button wrapping all navigation - Pull-to-refresh on all list/detail screens (home, explore, posts, chat, profile, ad detail, post detail) - Accessibility: labels on all interactive elements, roles on Pressable/Button, selectable text for user data - Deep linking: mojmajstor:// scheme configured in app.json, ad/[id] and chat/[id] routes work via Expo Router auto-routing - Pagination: cursor-based paginated queries for ads, posts, messages, chats, reviews with Load More buttons on frontend - Profile: shows customer posts with PostCard, loading/error states, create-post navigation - Auth error messages in Macedonian (Невалидни акредитиви, итн.) All UI text in Macedonian. TypeScript clean, Convex functions deployed.
183 lines
5.2 KiB
TypeScript
183 lines
5.2 KiB
TypeScript
import { query, mutation } from "./_generated/server";
|
||
import { v } from "convex/values";
|
||
import { paginationOptsValidator } from "convex/server";
|
||
|
||
export const listByUser = query({
|
||
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();
|
||
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);
|
||
},
|
||
});
|
||
|
||
export const listByUserPaginated = query({
|
||
args: { token: v.string(), paginationOpts: paginationOptsValidator },
|
||
handler: async (ctx, args) => {
|
||
const session = await ctx.db
|
||
.query("sessions")
|
||
.withIndex("by_token", (q) => q.eq("token", args.token))
|
||
.first();
|
||
if (!session) {
|
||
return { page: [], continuationCursor: null, isDone: true };
|
||
}
|
||
|
||
const result = await ctx.db
|
||
.query("chats")
|
||
.order("desc")
|
||
.paginate(args.paginationOpts);
|
||
|
||
const filtered = result.page.filter((chat) =>
|
||
chat.participantIds.includes(session.userId)
|
||
);
|
||
|
||
const enriched = await Promise.all(
|
||
filtered.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 {
|
||
page: enriched.sort((a, b) => b.lastMessageAt - a.lastMessageAt),
|
||
continueCursor: result.continueCursor,
|
||
isDone: result.isDone,
|
||
};
|
||
},
|
||
});
|
||
|
||
export const getOrCreate = mutation({
|
||
args: {
|
||
token: v.string(),
|
||
partnerId: v.id("users"),
|
||
},
|
||
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 allChats = await ctx.db.query("chats").collect();
|
||
const existing = allChats.find(
|
||
(c) =>
|
||
c.participantIds.includes(session.userId) &&
|
||
c.participantIds.includes(args.partnerId) &&
|
||
c.participantIds.length === 2
|
||
);
|
||
|
||
if (existing) return existing._id;
|
||
|
||
const chatId = await ctx.db.insert("chats", {
|
||
participantIds: [session.userId, args.partnerId],
|
||
lastMessageAt: Date.now(),
|
||
createdBy: session.userId,
|
||
});
|
||
|
||
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,
|
||
};
|
||
},
|
||
}); |