main f6d8405

aim.ts RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close