main f6d8405

queries.ts RawHistoryFolder

1import { query } from './_generated/server';
2import type { QueryCtx } from './_generated/server';
3import { authQuery } from './auth';
4import { v } from 'convex/values';
5import { toPublicUser, assertRoomAccess } from './aim';
6
7// Timeout threshold for marking users as offline (2 minutes)
8const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;
9
10// Helper to compute effective status based on lastSeen timeout.
11// Returns 'online', 'away', 'busy', 'idle', or 'offline'.
12// Note: This is a read-time computation - the database stores the last known status
13// and lastSeen timestamp. The client heartbeat (via /api/status) updates these values.
14// This function computes what to display when a user stops sending heartbeats.
15function computeEffectiveStatus(
16	status: string,
17	lastSeen: number | undefined
18): 'online' | 'away' | 'busy' | 'idle' | 'offline' {
19	if (status === 'offline') return 'offline';
20	const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
21	const effectiveLastSeen = lastSeen ?? 0;
22	if (effectiveLastSeen < timeoutThreshold) {
23		return 'offline';
24	}
25	return status as 'online' | 'away' | 'busy' | 'idle';
26}
27
28// ============ PUBLIC QUERIES (no auth required) ============
29
30// Visitors (not signed in) only see the latest messages of the default room.
31// Keep in sync with PUBLIC_PREVIEW_MESSAGES in src/lib/validation/message.ts.
32const PUBLIC_MESSAGE_LIMIT = 50;
33const MAX_AUTH_MESSAGE_LIMIT = 200;
34
35// Default room: "General", or the first group room when it does not exist
36async function findDefaultRoom(ctx: QueryCtx) {
37	const rooms = await ctx.db.query('chatRooms').collect();
38	return (
39		rooms.find((r) => r.name === 'General' && r.type === 'group') ??
40		rooms.find((r) => r.type === 'group') ??
41		null
42	);
43}
44
45// Public query: latest messages of the default room (real-time subscription)
46
47export const getMessagesPublic = query({
48	args: {
49		roomId: v.id('chatRooms'),
50		limit: v.optional(v.number())
51	},
52	handler: async (ctx, args) => {
53		// Only the default room is readable without an account
54		const defaultRoom = await findDefaultRoom(ctx);
55		if (!defaultRoom || defaultRoom._id !== args.roomId) return [];
56
57		// Enforce max limit and clamp to a safe positive integer
58		const rawLimit = args.limit ?? PUBLIC_MESSAGE_LIMIT;
59		const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit)));
60
61		const messages = await ctx.db
62			.query('messages')
63			.withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
64			.order('desc')
65			.take(limit);
66
67		// Enrich messages with sender info
68		const enrichedMessages = await Promise.all(
69			messages.map(async (msg) => {
70				const sender = await ctx.db.get(msg.senderId);
71				return {
72					id: msg._id,
73					chatRoomId: msg.chatRoomId,
74					senderId: msg.senderId,
75					content: msg.content,
76					type: msg.type,
77					timestamp: msg.timestamp,
78					styleData: msg.styleData,
79					hasFormatting: msg.hasFormatting,
80					sender: sender
81						? {
82								id: sender._id,
83								nickname: sender.nickname,
84								status: computeEffectiveStatus(sender.status, sender.lastSeen),
85								avatarUrl: sender.avatarUrl
86							}
87						: null
88				};
89			})
90		);
91
92		// Return in chronological order (oldest first)
93		return enrichedMessages.reverse();
94	}
95});
96
97// Authenticated query: older history of a room (pagination is members-only)
98export const getMessagesPage = authQuery({
99	args: {
100		roomId: v.id('chatRooms'),
101		limit: v.optional(v.number()),
102		beforeTimestamp: v.optional(v.number())
103	},
104	handler: async (ctx, args) => {
105		const room = await ctx.db.get(args.roomId);
106		if (!room) return { messages: [], hasMore: false };
107		await assertRoomAccess(ctx, room, ctx.user._id);
108
109		const rawLimit = args.limit ?? 50;
110		const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
111
112		let query = ctx.db
113			.query('messages')
114			.withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
115			.order('desc');
116
117		const beforeTimestamp = args.beforeTimestamp;
118		if (beforeTimestamp !== undefined) {
119			query = query.filter((q) => q.lt(q.field('timestamp'), beforeTimestamp));
120		}
121
122		const messages = await query.take(limit + 1);
123		const page = messages.slice(0, limit);
124
125		return {
126			messages: page.map((msg) => ({
127				id: msg._id,
128				chatRoomId: msg.chatRoomId,
129				senderId: msg.senderId,
130				content: msg.content,
131				type: msg.type,
132				timestamp: msg.timestamp,
133				styleData: msg.styleData,
134				hasFormatting: msg.hasFormatting
135			})),
136			hasMore: messages.length > limit
137		};
138	}
139});
140
141// Public query: Get all users (real-time subscription for buddy list)
142export const getUsersPublic = query({
143	args: {},
144	handler: async (ctx) => {
145		const users = await ctx.db.query('users').collect();
146
147		// Return safe user data (no passwords) with computed status
148		return users.map(toPublicUser);
149	}
150});
151
152// Public query: Get default room
153export const getDefaultRoomPublic = query({
154	args: {},
155	handler: async (ctx) => {
156		const room = await findDefaultRoom(ctx);
157		return room
158			? { id: room._id, name: room.name, type: room.type, createdAt: room.createdAt }
159			: null;
160	}
161});
162
163// ============ AUTHENTICATED QUERIES ============
164
165// Authenticated query: Get messages for a chat room (real-time subscription)
166export const getMessages = authQuery({
167	args: {
168		roomId: v.id('chatRooms'),
169		limit: v.optional(v.number())
170	},
171	handler: async (ctx, args) => {
172		const room = await ctx.db.get(args.roomId);
173		if (!room) return [];
174		await assertRoomAccess(ctx, room, ctx.user._id);
175
176		const rawLimit = args.limit ?? 100;
177		const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
178		const query = ctx.db
179			.query('messages')
180			.withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
181			.order('desc');
182
183		const messages = await query.take(limit);
184
185		// Enrich messages with sender info
186		const enrichedMessages = await Promise.all(
187			messages.map(async (msg) => {
188				const sender = await ctx.db.get(msg.senderId);
189				return {
190					id: msg._id,
191					chatRoomId: msg.chatRoomId,
192					senderId: msg.senderId,
193					content: msg.content,
194					type: msg.type,
195					timestamp: msg.timestamp,
196					styleData: msg.styleData,
197					hasFormatting: msg.hasFormatting,
198					sender: sender
199						? {
200								id: sender._id,
201								nickname: sender.nickname,
202								status: computeEffectiveStatus(sender.status, sender.lastSeen),
203								avatarUrl: sender.avatarUrl
204							}
205						: null
206				};
207			})
208		);
209
210		// Return in chronological order (oldest first)
211		return enrichedMessages.reverse();
212	}
213});
214
215// Authenticated query: Get all users (real-time subscription for buddy list)
216export const getUsers = authQuery({
217	args: {},
218	handler: async (ctx) => {
219		const users = await ctx.db.query('users').collect();
220
221		// Return safe user data (no passwords) with computed status
222		return users.map(toPublicUser);
223	}
224});
225
226// Authenticated query: Get chat rooms (direct rooms only when the user is a member)
227export const getChatRooms = authQuery({
228	args: {},
229	handler: async (ctx) => {
230		const rooms = (await ctx.db.query('chatRooms').collect()).filter(
231			(room) => room.type !== 'direct' || (room.memberIds ?? []).includes(ctx.user._id)
232		);
233		return rooms.map((room) => ({
234			id: room._id,
235			name: room.name,
236			type: room.type,
237			createdAt: room.createdAt
238		}));
239	}
240});
241
242// Authenticated query: Get default room (or first group room)
243export const getDefaultRoom = authQuery({
244	args: {},
245	handler: async (ctx) => {
246		const rooms = await ctx.db.query('chatRooms').collect();
247		const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group');
248
249		if (defaultRoom) {
250			return {
251				id: defaultRoom._id,
252				name: defaultRoom.name,
253				type: defaultRoom.type,
254				createdAt: defaultRoom.createdAt
255			};
256		}
257
258		// Return first group room if General doesn't exist
259		const firstGroup = rooms.find((r) => r.type === 'group');
260		if (firstGroup) {
261			return {
262				id: firstGroup._id,
263				name: firstGroup.name,
264				type: firstGroup.type,
265				createdAt: firstGroup.createdAt
266			};
267		}
268
269		return null;
270	}
271});
272
273// Authenticated query: Get current user info
274export const getCurrentUser = authQuery({
275	args: {},
276	handler: async (ctx) => {
277		// ctx.user is provided by authQuery
278		return {
279			id: ctx.user._id,
280			nickname: ctx.user.nickname,
281			status: ctx.user.status,
282			avatarUrl: ctx.user.avatarUrl,
283			lastSeen: ctx.user.lastSeen,
284			lastReadMentionTimestamp: ctx.user.lastReadMentionTimestamp
285		};
286	}
287});

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