1/**
2 * AIM features: buddy list, direct (IM) rooms, away messages, profiles,
3 * typing indicators and read cursors.
4 *
5 * Client-side access uses authQuery/authMutation with the session token hash
6 * (already exposed to the client as `session.id`).
7 */
8import { query } from './_generated/server';
9import { authQuery, authMutation } from './auth';
10import { v } from 'convex/values';
11import type { Doc, Id } from './_generated/dataModel';
12import type { QueryCtx } from './_generated/server';
13
14const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;
15const TYPING_TTL_MS = 6 * 1000;
16const MAX_AWAY_LENGTH = 300;
17const MAX_PROFILE_LENGTH = 1000;
18const MAX_ROOM_NAME_LENGTH = 40;
19const MAX_TOPIC_LENGTH = 120;
20const DEFAULT_GROUP = 'Buddies';
21// Unread badges stop counting here (shown as "99+")
22export const MAX_UNREAD_COUNT = 100;
23
24export type EffectiveStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline';
25
26export function computeEffectiveStatus(
27 status: string,
28 lastSeen: number | undefined
29): EffectiveStatus {
30 if (status === 'offline') return 'offline';
31 if ((lastSeen ?? 0) < Date.now() - ONLINE_TIMEOUT_MS) return 'offline';
32 return status as EffectiveStatus;
33}
34
35export function toPublicUser(user: Doc<'users'>) {
36 const status = computeEffectiveStatus(user.status, user.lastSeen);
37 return {
38 id: user._id,
39 nickname: user.nickname,
40 status,
41 avatarUrl: user.avatarUrl,
42 lastSeen: user.lastSeen,
43 awayMessage: status === 'away' ? user.awayMessage : undefined,
44 createdAt: user.createdAt
45 };
46}
47
48export function directKeyFor(a: Id<'users'>, b: Id<'users'>) {
49 return [a, b].sort().join(':');
50}
51
52export async function assertRoomAccess(ctx: QueryCtx, room: Doc<'chatRooms'>, userId: Id<'users'>) {
53 if (room.type === 'direct' && !(room.memberIds ?? []).includes(userId)) {
54 throw new Error('Not a member of this conversation');
55 }
56}
57
58// ============ PUBLIC ============
59
60export const getUserProfile = query({
61 args: { userId: v.id('users') },
62 handler: async (ctx, args) => {
63 const user = await ctx.db.get(args.userId);
64 if (!user) return null;
65 return { ...toPublicUser(user), profile: user.profile };
66 }
67});
68
69export const getGroupRooms = query({
70 args: {},
71 handler: async (ctx) => {
72 const rooms = await ctx.db.query('chatRooms').collect();
73 return rooms
74 .filter((r) => r.type === 'group')
75 .sort((a, b) => a.createdAt - b.createdAt)
76 .map((r) => ({
77 id: r._id,
78 name: r.name ?? 'Sans nom',
79 topic: r.topic,
80 createdAt: r.createdAt,
81 isDefault: r.name === 'General'
82 }));
83 }
84});
85
86// ============ AUTHENTICATED QUERIES ============
87
88export const getMyBuddies = authQuery({
89 args: {},
90 handler: async (ctx) => {
91 const rows = await ctx.db
92 .query('buddies')
93 .withIndex('by_user', (q) => q.eq('userId', ctx.user._id))
94 .collect();
95 const buddies = await Promise.all(
96 rows.map(async (row) => {
97 const user = await ctx.db.get(row.buddyId);
98 if (!user) return null;
99 return { ...toPublicUser(user), group: row.group, addedAt: row.createdAt };
100 })
101 );
102 return buddies.filter((b) => b !== null);
103 }
104});
105
106export const getMyDirectRooms = authQuery({
107 args: {},
108 handler: async (ctx) => {
109 const me = ctx.user._id;
110 const rooms = (await ctx.db.query('chatRooms').collect()).filter(
111 (r) => r.type === 'direct' && (r.memberIds ?? []).includes(me)
112 );
113 const reads = await ctx.db
114 .query('roomReads')
115 .withIndex('by_user', (q) => q.eq('userId', me))
116 .collect();
117 const readMap = new Map(reads.map((r) => [r.roomId, r.lastReadTimestamp]));
118
119 const result = await Promise.all(
120 rooms.map(async (room) => {
121 const otherId = (room.memberIds ?? []).find((id) => id !== me);
122 const other = otherId ? await ctx.db.get(otherId) : null;
123 const lastRead = readMap.get(room._id) ?? 0;
124 const lastMessage = await ctx.db
125 .query('messages')
126 .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', room._id))
127 .order('desc')
128 .first();
129 // Incoming messages after the read cursor, capped for the "99+" badge. Every index
130 // ends with _creationTime, so the range scan stops at the cursor.
131 const unreadCount = (
132 await ctx.db
133 .query('messages')
134 .withIndex('by_chatRoom', (q) =>
135 q.eq('chatRoomId', room._id).gt('_creationTime', lastRead)
136 )
137 .filter((q) => q.neq(q.field('senderId'), me))
138 .take(MAX_UNREAD_COUNT)
139 ).length;
140 return {
141 id: room._id,
142 other: other ? toPublicUser(other) : null,
143 lastMessage: lastMessage
144 ? {
145 content: lastMessage.content,
146 timestamp: lastMessage.timestamp,
147 senderId: lastMessage.senderId
148 }
149 : null,
150 unreadCount
151 };
152 })
153 );
154 return result.sort((a, b) => (b.lastMessage?.timestamp ?? 0) - (a.lastMessage?.timestamp ?? 0));
155 }
156});
157
158export const getRoomMessages = authQuery({
159 args: { roomId: v.id('chatRooms'), limit: v.optional(v.number()) },
160 handler: async (ctx, args) => {
161 const room = await ctx.db.get(args.roomId);
162 if (!room) return [];
163 await assertRoomAccess(ctx, room, ctx.user._id);
164 const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 100)));
165 const messages = await ctx.db
166 .query('messages')
167 .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
168 .order('desc')
169 .take(limit);
170 const enriched = await Promise.all(
171 messages.map(async (msg) => {
172 const sender = await ctx.db.get(msg.senderId);
173 return {
174 id: msg._id,
175 chatRoomId: msg.chatRoomId,
176 senderId: msg.senderId,
177 content: msg.content,
178 type: msg.type,
179 timestamp: msg.timestamp,
180 styleData: msg.styleData,
181 hasFormatting: msg.hasFormatting,
182 sender: sender
183 ? {
184 id: sender._id,
185 nickname: sender.nickname,
186 status: computeEffectiveStatus(sender.status, sender.lastSeen),
187 avatarUrl: sender.avatarUrl
188 }
189 : null
190 };
191 })
192 );
193 return enriched.reverse();
194 }
195});
196
197export const getRoomTyping = authQuery({
198 args: { roomId: v.id('chatRooms') },
199 handler: async (ctx, args) => {
200 const room = await ctx.db.get(args.roomId);
201 if (!room) return [];
202 await assertRoomAccess(ctx, room, ctx.user._id);
203 const since = Date.now() - TYPING_TTL_MS;
204 const rows = await ctx.db
205 .query('typing')
206 .withIndex('by_room', (q) => q.eq('roomId', args.roomId))
207 .collect();
208 const active = rows.filter((r) => r.updatedAt > since && r.userId !== ctx.user._id);
209 const users = await Promise.all(active.map((r) => ctx.db.get(r.userId)));
210 return users.filter((u) => u !== null).map((u) => ({ id: u._id, nickname: u.nickname }));
211 }
212});
213
214export const getMe = authQuery({
215 args: {},
216 handler: async (ctx) => {
217 const u = ctx.user;
218 return {
219 id: u._id,
220 nickname: u.nickname,
221 status: u.status,
222 awayMessage: u.awayMessage,
223 profile: u.profile,
224 createdAt: u.createdAt
225 };
226 }
227});
228
229// ============ AUTHENTICATED MUTATIONS ============
230
231export const setAwayMessage = authMutation({
232 args: { awayMessage: v.optional(v.string()) },
233 handler: async (ctx, args) => {
234 const text = args.awayMessage?.trim().slice(0, MAX_AWAY_LENGTH);
235 if (text) {
236 await ctx.db.patch(ctx.user._id, { awayMessage: text, status: 'away', lastSeen: Date.now() });
237 } else {
238 await ctx.db.patch(ctx.user._id, {
239 awayMessage: undefined,
240 status: 'online',
241 lastSeen: Date.now()
242 });
243 }
244 return { success: true };
245 }
246});
247
248export const updateProfile = authMutation({
249 args: { profile: v.optional(v.string()) },
250 handler: async (ctx, args) => {
251 const patch: Partial<Doc<'users'>> = {};
252 if (args.profile !== undefined)
253 patch.profile = args.profile.trim().slice(0, MAX_PROFILE_LENGTH);
254 await ctx.db.patch(ctx.user._id, patch);
255 return { success: true };
256 }
257});
258
259export const addBuddy = authMutation({
260 args: { buddyId: v.id('users'), group: v.optional(v.string()) },
261 handler: async (ctx, args) => {
262 if (args.buddyId === ctx.user._id) throw new Error('Impossible de s’ajouter soi-même');
263 const buddy = await ctx.db.get(args.buddyId);
264 if (!buddy) throw new Error('User not found');
265 const group = (args.group?.trim() || DEFAULT_GROUP).slice(0, 30);
266 const existing = await ctx.db
267 .query('buddies')
268 .withIndex('by_user_buddy', (q) => q.eq('userId', ctx.user._id).eq('buddyId', args.buddyId))
269 .first();
270 if (existing) {
271 await ctx.db.patch(existing._id, { group });
272 return { success: true, id: existing._id };
273 }
274 const id = await ctx.db.insert('buddies', {
275 userId: ctx.user._id,
276 buddyId: args.buddyId,
277 group,
278 createdAt: Date.now()
279 });
280 return { success: true, id };
281 }
282});
283
284export const removeBuddy = authMutation({
285 args: { buddyId: v.id('users') },
286 handler: async (ctx, args) => {
287 const existing = await ctx.db
288 .query('buddies')
289 .withIndex('by_user_buddy', (q) => q.eq('userId', ctx.user._id).eq('buddyId', args.buddyId))
290 .first();
291 if (existing) await ctx.db.delete(existing._id);
292 return { success: true };
293 }
294});
295
296export const getOrCreateDirectRoom = authMutation({
297 args: { otherUserId: v.id('users') },
298 handler: async (ctx, args) => {
299 if (args.otherUserId === ctx.user._id) throw new Error('Cannot IM yourself');
300 const other = await ctx.db.get(args.otherUserId);
301 if (!other) throw new Error('User not found');
302 const key = directKeyFor(ctx.user._id, args.otherUserId);
303 const existing = await ctx.db
304 .query('chatRooms')
305 .withIndex('by_directKey', (q) => q.eq('directKey', key))
306 .first();
307 if (existing) return { id: existing._id };
308 const id = await ctx.db.insert('chatRooms', {
309 type: 'direct',
310 createdAt: Date.now(),
311 createdBy: ctx.user._id,
312 memberIds: [ctx.user._id, args.otherUserId],
313 directKey: key
314 });
315 return { id };
316 }
317});
318
319export const createGroupRoom = authMutation({
320 args: { name: v.string(), topic: v.optional(v.string()) },
321 handler: async (ctx, args) => {
322 const name = args.name.trim().slice(0, MAX_ROOM_NAME_LENGTH);
323 if (name.length < 2) throw new Error('Nom de salon trop court');
324 const rooms = await ctx.db.query('chatRooms').collect();
325 const clash = rooms.find(
326 (r) => r.type === 'group' && (r.name ?? '').toLowerCase() === name.toLowerCase()
327 );
328 if (clash) return { id: clash._id, existed: true };
329 const id = await ctx.db.insert('chatRooms', {
330 name,
331 type: 'group',
332 topic: args.topic?.trim().slice(0, MAX_TOPIC_LENGTH) || undefined,
333 createdAt: Date.now(),
334 createdBy: ctx.user._id
335 });
336 return { id, existed: false };
337 }
338});
339
340export const markRoomRead = authMutation({
341 args: { roomId: v.id('chatRooms') },
342 handler: async (ctx, args) => {
343 const room = await ctx.db.get(args.roomId);
344 if (!room) throw new Error('Chat room not found');
345 await assertRoomAccess(ctx, room, ctx.user._id);
346 const now = Date.now();
347 const existing = await ctx.db
348 .query('roomReads')
349 .withIndex('by_user_room', (q) => q.eq('userId', ctx.user._id).eq('roomId', args.roomId))
350 .first();
351 if (existing) {
352 if (existing.lastReadTimestamp < now) {
353 await ctx.db.patch(existing._id, { lastReadTimestamp: now });
354 }
355 } else {
356 await ctx.db.insert('roomReads', {
357 userId: ctx.user._id,
358 roomId: args.roomId,
359 lastReadTimestamp: now
360 });
361 }
362 return { success: true };
363 }
364});
365
366export const setTyping = authMutation({
367 args: { roomId: v.id('chatRooms'), typing: v.boolean() },
368 handler: async (ctx, args) => {
369 const room = await ctx.db.get(args.roomId);
370 if (!room) throw new Error('Chat room not found');
371 await assertRoomAccess(ctx, room, ctx.user._id);
372 const existing = await ctx.db
373 .query('typing')
374 .withIndex('by_user_room', (q) => q.eq('userId', ctx.user._id).eq('roomId', args.roomId))
375 .first();
376 if (!args.typing) {
377 if (existing) await ctx.db.delete(existing._id);
378 return { success: true };
379 }
380 if (existing) {
381 await ctx.db.patch(existing._id, { updatedAt: Date.now() });
382 } else {
383 await ctx.db.insert('typing', {
384 userId: ctx.user._id,
385 roomId: args.roomId,
386 updatedAt: Date.now()
387 });
388 }
389 return { success: true };
390 }
391});