detached 2a6dd11

feat(aim): add Convex AIM backend, sounds, window manager and XP window shell

commit
2a6dd11d638caca76be26713564ba51f378fbc26
author
Kit · · 2026-09-27 12:39 UTC
parent
66b04ed
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
31 files · +1436 -21
M src/convex/_generated/api.d.ts +2 -0
@@ -8,6 +8,7 @@
88 * @module
99 */
1010
11+import type * as aim from "../aim.js";
1112 import type * as auth from "../auth.js";
1213 import type * as chatRoomsInternal from "../chatRoomsInternal.js";
1314 import type * as crons from "../crons.js";
@@ -26,6 +27,7 @@ import type {
2627 } from "convex/server";
2728
2829 declare const fullApi: ApiFromModules<{
30+ aim: typeof aim;
2931 auth: typeof auth;
3032 chatRoomsInternal: typeof chatRoomsInternal;
3133 crons: typeof crons;
@@ -8,6 +8,7 @@
8 * @module8 * @module
9 */9 */
10 10
11+import type * as aim from "../aim.js";
11 import type * as auth from "../auth.js";12 import type * as auth from "../auth.js";
12 import type * as chatRoomsInternal from "../chatRoomsInternal.js";13 import type * as chatRoomsInternal from "../chatRoomsInternal.js";
13 import type * as crons from "../crons.js";14 import type * as crons from "../crons.js";
@@ -26,6 +27,7 @@ import type {
26 } from "convex/server";27 } from "convex/server";
27 28
28 declare const fullApi: ApiFromModules<{29 declare const fullApi: ApiFromModules<{
30+ aim: typeof aim;
29 auth: typeof auth;31 auth: typeof auth;
30 chatRoomsInternal: typeof chatRoomsInternal;32 chatRoomsInternal: typeof chatRoomsInternal;
31 crons: typeof crons;33 crons: typeof crons;
M src/convex/_generated/server.d.ts +19 -0
@@ -21,6 +21,17 @@ import {
2121 } from "convex/server";
2222 import type { DataModel } from "./dataModel.js";
2323
24+/**
25+ * Typesafe environment variables.
26+ *
27+ * This includes platform-provided env vars and any variables declared in
28+ * `convex.config.ts`.
29+ */
30+type Env = {
31+ readonly CONVEX_CLOUD_URL: string;
32+ readonly CONVEX_SITE_URL: string;
33+};
34+
2435 /**
2536 * Define a query in this Convex app's public API.
2637 *
@@ -95,6 +106,14 @@ export declare const internalAction: ActionBuilder<DataModel, "internal">;
95106 */
96107 export declare const httpAction: HttpActionBuilder;
97108
109+/**
110+ * Typesafe environment variables.
111+ *
112+ * This includes platform-provided env vars and any variables declared in
113+ * `convex.config.ts`.
114+ */
115+export declare const env: Env;
116+
98117 /**
99118 * A set of services for use within Convex query functions.
100119 *
@@ -21,6 +21,17 @@ import {
21 } from "convex/server";21 } from "convex/server";
22 import type { DataModel } from "./dataModel.js";22 import type { DataModel } from "./dataModel.js";
23 23
24+/**
25+ * Typesafe environment variables.
26+ *
27+ * This includes platform-provided env vars and any variables declared in
28+ * `convex.config.ts`.
29+ */
30+type Env = {
31+ readonly CONVEX_CLOUD_URL: string;
32+ readonly CONVEX_SITE_URL: string;
33+};
34+
24 /**35 /**
25 * Define a query in this Convex app's public API.36 * Define a query in this Convex app's public API.
26 *37 *
@@ -95,6 +106,14 @@ export declare const internalAction: ActionBuilder<DataModel, "internal">;
95 */106 */
96 export declare const httpAction: HttpActionBuilder;107 export declare const httpAction: HttpActionBuilder;
97 108
109+/**
110+ * Typesafe environment variables.
111+ *
112+ * This includes platform-provided env vars and any variables declared in
113+ * `convex.config.ts`.
114+ */
115+export declare const env: Env;
116+
98 /**117 /**
99 * A set of services for use within Convex query functions.118 * A set of services for use within Convex query functions.
100 *119 *
M src/convex/_generated/server.js +8 -0
@@ -91,3 +91,11 @@ export const internalAction = internalActionGeneric;
9191 * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
9292 */
9393 export const httpAction = httpActionGeneric;
94+
95+/**
96+ * Typesafe environment variables.
97+ *
98+ * This includes platform-provided env vars and any variables declared in
99+ * `convex.config.ts`.
100+ */
101+export const env = process.env;
@@ -91,3 +91,11 @@ export const internalAction = internalActionGeneric;
91 * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.91 * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
92 */92 */
93 export const httpAction = httpActionGeneric;93 export const httpAction = httpActionGeneric;
94+
95+/**
96+ * Typesafe environment variables.
97+ *
98+ * This includes platform-provided env vars and any variables declared in
99+ * `convex.config.ts`.
100+ */
101+export const env = process.env;
A src/convex/aim.ts +385 -0
@@ -0,0 +1,385 @@
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+ */
8+import { query } from './_generated/server';
9+import { authQuery, authMutation } from './auth';
10+import { v } from 'convex/values';
11+import type { Doc, Id } from './_generated/dataModel';
12+import type { QueryCtx } from './_generated/server';
13+
14+const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;
15+const TYPING_TTL_MS = 6 * 1000;
16+const MAX_AWAY_LENGTH = 300;
17+const MAX_PROFILE_LENGTH = 1000;
18+const MAX_ROOM_NAME_LENGTH = 40;
19+const MAX_TOPIC_LENGTH = 120;
20+const DEFAULT_GROUP = 'Buddies';
21+
22+export type EffectiveStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline';
23+
24+export function computeEffectiveStatus(
25+ status: string,
26+ lastSeen: number | undefined
27+): EffectiveStatus {
28+ if (status === 'offline') return 'offline';
29+ if ((lastSeen ?? 0) < Date.now() - ONLINE_TIMEOUT_MS) return 'offline';
30+ return status as EffectiveStatus;
31+}
32+
33+export function toPublicUser(user: Doc<'users'>) {
34+ const status = computeEffectiveStatus(user.status, user.lastSeen);
35+ return {
36+ id: user._id,
37+ nickname: user.nickname,
38+ status,
39+ avatarUrl: user.avatarUrl,
40+ lastSeen: user.lastSeen,
41+ awayMessage: status === 'away' ? user.awayMessage : undefined,
42+ buddyIcon: user.buddyIcon,
43+ createdAt: user.createdAt
44+ };
45+}
46+
47+export function directKeyFor(a: Id<'users'>, b: Id<'users'>) {
48+ return [a, b].sort().join(':');
49+}
50+
51+export async function assertRoomAccess(
52+ ctx: QueryCtx,
53+ room: Doc<'chatRooms'>,
54+ userId: Id<'users'>
55+) {
56+ if (room.type === 'direct' && !(room.memberIds ?? []).includes(userId)) {
57+ throw new Error('Not a member of this conversation');
58+ }
59+}
60+
61+// ============ PUBLIC ============
62+
63+export const getUserProfile = query({
64+ args: { userId: v.id('users') },
65+ handler: async (ctx, args) => {
66+ const user = await ctx.db.get(args.userId);
67+ if (!user) return null;
68+ return { ...toPublicUser(user), profile: user.profile };
69+ }
70+});
71+
72+export const getGroupRooms = query({
73+ args: {},
74+ handler: async (ctx) => {
75+ const rooms = await ctx.db.query('chatRooms').collect();
76+ return rooms
77+ .filter((r) => r.type === 'group')
78+ .sort((a, b) => a.createdAt - b.createdAt)
79+ .map((r) => ({
80+ id: r._id,
81+ name: r.name ?? 'Sans nom',
82+ topic: r.topic,
83+ createdAt: r.createdAt,
84+ isDefault: r.name === 'General'
85+ }));
86+ }
87+});
88+
89+// ============ AUTHENTICATED QUERIES ============
90+
91+export const getMyBuddies = authQuery({
92+ args: {},
93+ handler: async (ctx) => {
94+ const rows = await ctx.db
95+ .query('buddies')
96+ .withIndex('by_user', (q) => q.eq('userId', ctx.user._id))
97+ .collect();
98+ const buddies = await Promise.all(
99+ rows.map(async (row) => {
100+ const user = await ctx.db.get(row.buddyId);
101+ if (!user) return null;
102+ return { ...toPublicUser(user), group: row.group, addedAt: row.createdAt };
103+ })
104+ );
105+ return buddies.filter((b) => b !== null);
106+ }
107+});
108+
109+export const getMyDirectRooms = authQuery({
110+ args: {},
111+ handler: async (ctx) => {
112+ const me = ctx.user._id;
113+ const rooms = (await ctx.db.query('chatRooms').collect()).filter(
114+ (r) => r.type === 'direct' && (r.memberIds ?? []).includes(me)
115+ );
116+ const reads = await ctx.db
117+ .query('roomReads')
118+ .withIndex('by_user', (q) => q.eq('userId', me))
119+ .collect();
120+ const readMap = new Map(reads.map((r) => [r.roomId, r.lastReadTimestamp]));
121+
122+ const result = await Promise.all(
123+ rooms.map(async (room) => {
124+ const otherId = (room.memberIds ?? []).find((id) => id !== me);
125+ const other = otherId ? await ctx.db.get(otherId) : null;
126+ const lastRead = readMap.get(room._id) ?? 0;
127+ const recent = await ctx.db
128+ .query('messages')
129+ .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', room._id))
130+ .order('desc')
131+ .take(50);
132+ const lastMessage = recent[0] ?? null;
133+ const unreadCount = recent.filter(
134+ (m) => m.timestamp > lastRead && m.senderId !== me
135+ ).length;
136+ return {
137+ id: room._id,
138+ other: other ? toPublicUser(other) : null,
139+ lastMessage: lastMessage
140+ ? {
141+ content: lastMessage.content,
142+ timestamp: lastMessage.timestamp,
143+ senderId: lastMessage.senderId
144+ }
145+ : null,
146+ unreadCount
147+ };
148+ })
149+ );
150+ return result.sort(
151+ (a, b) => (b.lastMessage?.timestamp ?? 0) - (a.lastMessage?.timestamp ?? 0)
152+ );
153+ }
154+});
155+
156+export const getRoomMessages = authQuery({
157+ args: { roomId: v.id('chatRooms'), limit: v.optional(v.number()) },
158+ handler: async (ctx, args) => {
159+ const room = await ctx.db.get(args.roomId);
160+ if (!room) return [];
161+ await assertRoomAccess(ctx, room, ctx.user._id);
162+ const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 100)));
163+ const messages = await ctx.db
164+ .query('messages')
165+ .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
166+ .order('desc')
167+ .take(limit);
168+ const enriched = await Promise.all(
169+ messages.map(async (msg) => {
170+ const sender = await ctx.db.get(msg.senderId);
171+ return {
172+ id: msg._id,
173+ chatRoomId: msg.chatRoomId,
174+ senderId: msg.senderId,
175+ content: msg.content,
176+ type: msg.type,
177+ timestamp: msg.timestamp,
178+ styleData: msg.styleData,
179+ hasFormatting: msg.hasFormatting,
180+ sender: sender
181+ ? {
182+ id: sender._id,
183+ nickname: sender.nickname,
184+ status: computeEffectiveStatus(sender.status, sender.lastSeen),
185+ avatarUrl: sender.avatarUrl,
186+ buddyIcon: sender.buddyIcon
187+ }
188+ : null
189+ };
190+ })
191+ );
192+ return enriched.reverse();
193+ }
194+});
195+
196+export const getRoomTyping = authQuery({
197+ args: { roomId: v.id('chatRooms') },
198+ handler: async (ctx, args) => {
199+ const since = Date.now() - TYPING_TTL_MS;
200+ const rows = await ctx.db
201+ .query('typing')
202+ .withIndex('by_room', (q) => q.eq('roomId', args.roomId))
203+ .collect();
204+ const active = rows.filter((r) => r.updatedAt > since && r.userId !== ctx.user._id);
205+ const users = await Promise.all(active.map((r) => ctx.db.get(r.userId)));
206+ return users.filter((u) => u !== null).map((u) => ({ id: u._id, nickname: u.nickname }));
207+ }
208+});
209+
210+export const getMe = authQuery({
211+ args: {},
212+ handler: async (ctx) => {
213+ const u = ctx.user;
214+ return {
215+ id: u._id,
216+ nickname: u.nickname,
217+ status: u.status,
218+ awayMessage: u.awayMessage,
219+ profile: u.profile,
220+ buddyIcon: u.buddyIcon,
221+ createdAt: u.createdAt
222+ };
223+ }
224+});
225+
226+// ============ AUTHENTICATED MUTATIONS ============
227+
228+export const setAwayMessage = authMutation({
229+ args: { awayMessage: v.optional(v.string()) },
230+ handler: async (ctx, args) => {
231+ const text = args.awayMessage?.trim().slice(0, MAX_AWAY_LENGTH);
232+ if (text) {
233+ await ctx.db.patch(ctx.user._id, { awayMessage: text, status: 'away', lastSeen: Date.now() });
234+ } else {
235+ await ctx.db.patch(ctx.user._id, {
236+ awayMessage: undefined,
237+ status: 'online',
238+ lastSeen: Date.now()
239+ });
240+ }
241+ return { success: true };
242+ }
243+});
244+
245+export const updateProfile = authMutation({
246+ args: { profile: v.optional(v.string()), buddyIcon: v.optional(v.string()) },
247+ handler: async (ctx, args) => {
248+ const patch: Partial<Doc<'users'>> = {};
249+ if (args.profile !== undefined) patch.profile = args.profile.trim().slice(0, MAX_PROFILE_LENGTH);
250+ if (args.buddyIcon !== undefined) {
251+ if (!/^[a-z0-9-]{1,32}$/.test(args.buddyIcon)) throw new Error('Invalid buddy icon');
252+ patch.buddyIcon = args.buddyIcon;
253+ }
254+ await ctx.db.patch(ctx.user._id, patch);
255+ return { success: true };
256+ }
257+});
258+
259+export 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+
284+export 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+
296+export 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+
319+export 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+
340+export const markRoomRead = authMutation({
341+ args: { roomId: v.id('chatRooms') },
342+ handler: async (ctx, args) => {
343+ const now = Date.now();
344+ const existing = await ctx.db
345+ .query('roomReads')
346+ .withIndex('by_user_room', (q) => q.eq('userId', ctx.user._id).eq('roomId', args.roomId))
347+ .first();
348+ if (existing) {
349+ if (existing.lastReadTimestamp < now) {
350+ await ctx.db.patch(existing._id, { lastReadTimestamp: now });
351+ }
352+ } else {
353+ await ctx.db.insert('roomReads', {
354+ userId: ctx.user._id,
355+ roomId: args.roomId,
356+ lastReadTimestamp: now
357+ });
358+ }
359+ return { success: true };
360+ }
361+});
362+
363+export const setTyping = authMutation({
364+ args: { roomId: v.id('chatRooms'), typing: v.boolean() },
365+ handler: async (ctx, args) => {
366+ const existing = await ctx.db
367+ .query('typing')
368+ .withIndex('by_user_room', (q) => q.eq('userId', ctx.user._id).eq('roomId', args.roomId))
369+ .first();
370+ if (!args.typing) {
371+ if (existing) await ctx.db.delete(existing._id);
372+ return { success: true };
373+ }
374+ if (existing) {
375+ await ctx.db.patch(existing._id, { updatedAt: Date.now() });
376+ } else {
377+ await ctx.db.insert('typing', {
378+ userId: ctx.user._id,
379+ roomId: args.roomId,
380+ updatedAt: Date.now()
381+ });
382+ }
383+ return { success: true };
384+ }
385+});
@@ -0,0 +1,385 @@
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+ */
8+import { query } from './_generated/server';
9+import { authQuery, authMutation } from './auth';
10+import { v } from 'convex/values';
11+import type { Doc, Id } from './_generated/dataModel';
12+import type { QueryCtx } from './_generated/server';
13+
14+const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;
15+const TYPING_TTL_MS = 6 * 1000;
16+const MAX_AWAY_LENGTH = 300;
17+const MAX_PROFILE_LENGTH = 1000;
18+const MAX_ROOM_NAME_LENGTH = 40;
19+const MAX_TOPIC_LENGTH = 120;
20+const DEFAULT_GROUP = 'Buddies';
21+
22+export type EffectiveStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline';
23+
24+export function computeEffectiveStatus(
25+ status: string,
26+ lastSeen: number | undefined
27+): EffectiveStatus {
28+ if (status === 'offline') return 'offline';
29+ if ((lastSeen ?? 0) < Date.now() - ONLINE_TIMEOUT_MS) return 'offline';
30+ return status as EffectiveStatus;
31+}
32+
33+export function toPublicUser(user: Doc<'users'>) {
34+ const status = computeEffectiveStatus(user.status, user.lastSeen);
35+ return {
36+ id: user._id,
37+ nickname: user.nickname,
38+ status,
39+ avatarUrl: user.avatarUrl,
40+ lastSeen: user.lastSeen,
41+ awayMessage: status === 'away' ? user.awayMessage : undefined,
42+ buddyIcon: user.buddyIcon,
43+ createdAt: user.createdAt
44+ };
45+}
46+
47+export function directKeyFor(a: Id<'users'>, b: Id<'users'>) {
48+ return [a, b].sort().join(':');
49+}
50+
51+export async function assertRoomAccess(
52+ ctx: QueryCtx,
53+ room: Doc<'chatRooms'>,
54+ userId: Id<'users'>
55+) {
56+ if (room.type === 'direct' && !(room.memberIds ?? []).includes(userId)) {
57+ throw new Error('Not a member of this conversation');
58+ }
59+}
60+
61+// ============ PUBLIC ============
62+
63+export const getUserProfile = query({
64+ args: { userId: v.id('users') },
65+ handler: async (ctx, args) => {
66+ const user = await ctx.db.get(args.userId);
67+ if (!user) return null;
68+ return { ...toPublicUser(user), profile: user.profile };
69+ }
70+});
71+
72+export const getGroupRooms = query({
73+ args: {},
74+ handler: async (ctx) => {
75+ const rooms = await ctx.db.query('chatRooms').collect();
76+ return rooms
77+ .filter((r) => r.type === 'group')
78+ .sort((a, b) => a.createdAt - b.createdAt)
79+ .map((r) => ({
80+ id: r._id,
81+ name: r.name ?? 'Sans nom',
82+ topic: r.topic,
83+ createdAt: r.createdAt,
84+ isDefault: r.name === 'General'
85+ }));
86+ }
87+});
88+
89+// ============ AUTHENTICATED QUERIES ============
90+
91+export const getMyBuddies = authQuery({
92+ args: {},
93+ handler: async (ctx) => {
94+ const rows = await ctx.db
95+ .query('buddies')
96+ .withIndex('by_user', (q) => q.eq('userId', ctx.user._id))
97+ .collect();
98+ const buddies = await Promise.all(
99+ rows.map(async (row) => {
100+ const user = await ctx.db.get(row.buddyId);
101+ if (!user) return null;
102+ return { ...toPublicUser(user), group: row.group, addedAt: row.createdAt };
103+ })
104+ );
105+ return buddies.filter((b) => b !== null);
106+ }
107+});
108+
109+export const getMyDirectRooms = authQuery({
110+ args: {},
111+ handler: async (ctx) => {
112+ const me = ctx.user._id;
113+ const rooms = (await ctx.db.query('chatRooms').collect()).filter(
114+ (r) => r.type === 'direct' && (r.memberIds ?? []).includes(me)
115+ );
116+ const reads = await ctx.db
117+ .query('roomReads')
118+ .withIndex('by_user', (q) => q.eq('userId', me))
119+ .collect();
120+ const readMap = new Map(reads.map((r) => [r.roomId, r.lastReadTimestamp]));
121+
122+ const result = await Promise.all(
123+ rooms.map(async (room) => {
124+ const otherId = (room.memberIds ?? []).find((id) => id !== me);
125+ const other = otherId ? await ctx.db.get(otherId) : null;
126+ const lastRead = readMap.get(room._id) ?? 0;
127+ const recent = await ctx.db
128+ .query('messages')
129+ .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', room._id))
130+ .order('desc')
131+ .take(50);
132+ const lastMessage = recent[0] ?? null;
133+ const unreadCount = recent.filter(
134+ (m) => m.timestamp > lastRead && m.senderId !== me
135+ ).length;
136+ return {
137+ id: room._id,
138+ other: other ? toPublicUser(other) : null,
139+ lastMessage: lastMessage
140+ ? {
141+ content: lastMessage.content,
142+ timestamp: lastMessage.timestamp,
143+ senderId: lastMessage.senderId
144+ }
145+ : null,
146+ unreadCount
147+ };
148+ })
149+ );
150+ return result.sort(
151+ (a, b) => (b.lastMessage?.timestamp ?? 0) - (a.lastMessage?.timestamp ?? 0)
152+ );
153+ }
154+});
155+
156+export const getRoomMessages = authQuery({
157+ args: { roomId: v.id('chatRooms'), limit: v.optional(v.number()) },
158+ handler: async (ctx, args) => {
159+ const room = await ctx.db.get(args.roomId);
160+ if (!room) return [];
161+ await assertRoomAccess(ctx, room, ctx.user._id);
162+ const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 100)));
163+ const messages = await ctx.db
164+ .query('messages')
165+ .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
166+ .order('desc')
167+ .take(limit);
168+ const enriched = await Promise.all(
169+ messages.map(async (msg) => {
170+ const sender = await ctx.db.get(msg.senderId);
171+ return {
172+ id: msg._id,
173+ chatRoomId: msg.chatRoomId,
174+ senderId: msg.senderId,
175+ content: msg.content,
176+ type: msg.type,
177+ timestamp: msg.timestamp,
178+ styleData: msg.styleData,
179+ hasFormatting: msg.hasFormatting,
180+ sender: sender
181+ ? {
182+ id: sender._id,
183+ nickname: sender.nickname,
184+ status: computeEffectiveStatus(sender.status, sender.lastSeen),
185+ avatarUrl: sender.avatarUrl,
186+ buddyIcon: sender.buddyIcon
187+ }
188+ : null
189+ };
190+ })
191+ );
192+ return enriched.reverse();
193+ }
194+});
195+
196+export const getRoomTyping = authQuery({
197+ args: { roomId: v.id('chatRooms') },
198+ handler: async (ctx, args) => {
199+ const since = Date.now() - TYPING_TTL_MS;
200+ const rows = await ctx.db
201+ .query('typing')
202+ .withIndex('by_room', (q) => q.eq('roomId', args.roomId))
203+ .collect();
204+ const active = rows.filter((r) => r.updatedAt > since && r.userId !== ctx.user._id);
205+ const users = await Promise.all(active.map((r) => ctx.db.get(r.userId)));
206+ return users.filter((u) => u !== null).map((u) => ({ id: u._id, nickname: u.nickname }));
207+ }
208+});
209+
210+export const getMe = authQuery({
211+ args: {},
212+ handler: async (ctx) => {
213+ const u = ctx.user;
214+ return {
215+ id: u._id,
216+ nickname: u.nickname,
217+ status: u.status,
218+ awayMessage: u.awayMessage,
219+ profile: u.profile,
220+ buddyIcon: u.buddyIcon,
221+ createdAt: u.createdAt
222+ };
223+ }
224+});
225+
226+// ============ AUTHENTICATED MUTATIONS ============
227+
228+export const setAwayMessage = authMutation({
229+ args: { awayMessage: v.optional(v.string()) },
230+ handler: async (ctx, args) => {
231+ const text = args.awayMessage?.trim().slice(0, MAX_AWAY_LENGTH);
232+ if (text) {
233+ await ctx.db.patch(ctx.user._id, { awayMessage: text, status: 'away', lastSeen: Date.now() });
234+ } else {
235+ await ctx.db.patch(ctx.user._id, {
236+ awayMessage: undefined,
237+ status: 'online',
238+ lastSeen: Date.now()
239+ });
240+ }
241+ return { success: true };
242+ }
243+});
244+
245+export const updateProfile = authMutation({
246+ args: { profile: v.optional(v.string()), buddyIcon: v.optional(v.string()) },
247+ handler: async (ctx, args) => {
248+ const patch: Partial<Doc<'users'>> = {};
249+ if (args.profile !== undefined) patch.profile = args.profile.trim().slice(0, MAX_PROFILE_LENGTH);
250+ if (args.buddyIcon !== undefined) {
251+ if (!/^[a-z0-9-]{1,32}$/.test(args.buddyIcon)) throw new Error('Invalid buddy icon');
252+ patch.buddyIcon = args.buddyIcon;
253+ }
254+ await ctx.db.patch(ctx.user._id, patch);
255+ return { success: true };
256+ }
257+});
258+
259+export 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+
284+export 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+
296+export 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+
319+export 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+
340+export const markRoomRead = authMutation({
341+ args: { roomId: v.id('chatRooms') },
342+ handler: async (ctx, args) => {
343+ const now = Date.now();
344+ const existing = await ctx.db
345+ .query('roomReads')
346+ .withIndex('by_user_room', (q) => q.eq('userId', ctx.user._id).eq('roomId', args.roomId))
347+ .first();
348+ if (existing) {
349+ if (existing.lastReadTimestamp < now) {
350+ await ctx.db.patch(existing._id, { lastReadTimestamp: now });
351+ }
352+ } else {
353+ await ctx.db.insert('roomReads', {
354+ userId: ctx.user._id,
355+ roomId: args.roomId,
356+ lastReadTimestamp: now
357+ });
358+ }
359+ return { success: true };
360+ }
361+});
362+
363+export const setTyping = authMutation({
364+ args: { roomId: v.id('chatRooms'), typing: v.boolean() },
365+ handler: async (ctx, args) => {
366+ const existing = await ctx.db
367+ .query('typing')
368+ .withIndex('by_user_room', (q) => q.eq('userId', ctx.user._id).eq('roomId', args.roomId))
369+ .first();
370+ if (!args.typing) {
371+ if (existing) await ctx.db.delete(existing._id);
372+ return { success: true };
373+ }
374+ if (existing) {
375+ await ctx.db.patch(existing._id, { updatedAt: Date.now() });
376+ } else {
377+ await ctx.db.insert('typing', {
378+ userId: ctx.user._id,
379+ roomId: args.roomId,
380+ updatedAt: Date.now()
381+ });
382+ }
383+ return { success: true };
384+ }
385+});
M src/convex/messagesInternal.ts +5 -0
@@ -57,6 +57,11 @@ export const send = internalMutation({
5757 throw new Error('User not found');
5858 }
5959
60+ // Direct rooms are restricted to their two members
61+ if (room.type === 'direct' && !(room.memberIds ?? []).includes(args.senderId)) {
62+ throw new Error('Not a member of this conversation');
63+ }
64+
6065 const messageId = await ctx.db.insert('messages', {
6166 chatRoomId: args.chatRoomId,
6267 senderId: args.senderId,
@@ -57,6 +57,11 @@ export const send = internalMutation({
57 throw new Error('User not found');57 throw new Error('User not found');
58 }58 }
59 59
60+ // Direct rooms are restricted to their two members
61+ if (room.type === 'direct' && !(room.memberIds ?? []).includes(args.senderId)) {
62+ throw new Error('Not a member of this conversation');
63+ }
64+
60 const messageId = await ctx.db.insert('messages', {65 const messageId = await ctx.db.insert('messages', {
61 chatRoomId: args.chatRoomId,66 chatRoomId: args.chatRoomId,
62 senderId: args.senderId,67 senderId: args.senderId,
M src/convex/mutations.ts +3 -0
@@ -20,6 +20,9 @@ export const sendMessage = authMutation({
2020 if (!room) {
2121 throw new Error('Chat room not found');
2222 }
23+ if (room.type === 'direct' && !(room.memberIds ?? []).includes(user._id)) {
24+ throw new Error('Not a member of this conversation');
25+ }
2326
2427 const messageId = await ctx.db.insert('messages', {
2528 chatRoomId: args.chatRoomId,
@@ -20,6 +20,9 @@ export const sendMessage = authMutation({
20 if (!room) {20 if (!room) {
21 throw new Error('Chat room not found');21 throw new Error('Chat room not found');
22 }22 }
23+ if (room.type === 'direct' && !(room.memberIds ?? []).includes(user._id)) {
24+ throw new Error('Not a member of this conversation');
25+ }
23 26
24 const messageId = await ctx.db.insert('messages', {27 const messageId = await ctx.db.insert('messages', {
25 chatRoomId: args.chatRoomId,28 chatRoomId: args.chatRoomId,
M src/convex/queries.ts +3 -14
@@ -1,6 +1,7 @@
11 import { query } from './_generated/server';
22 import { authQuery } from './auth';
33 import { v } from 'convex/values';
4+import { toPublicUser } from './aim';
45
56 // Timeout threshold for marking users as offline (2 minutes)
67 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;
@@ -122,13 +123,7 @@ export const getUsersPublic = query({
122123 const users = await ctx.db.query('users').collect();
123124
124125 // Return safe user data (no passwords) with computed status
125- return users.map((user) => ({
126- id: user._id,
127- nickname: user.nickname,
128- status: computeEffectiveStatus(user.status, user.lastSeen),
129- avatarUrl: user.avatarUrl,
130- lastSeen: user.lastSeen
131- }));
126+ return users.map(toPublicUser);
132127 }
133128 });
134129
@@ -219,13 +214,7 @@ export const getUsers = authQuery({
219214 const users = await ctx.db.query('users').collect();
220215
221216 // Return safe user data (no passwords) with computed status
222- return users.map((user) => ({
223- id: user._id,
224- nickname: user.nickname,
225- status: computeEffectiveStatus(user.status, user.lastSeen),
226- avatarUrl: user.avatarUrl,
227- lastSeen: user.lastSeen
228- }));
217+ return users.map(toPublicUser);
229218 }
230219 });
231220
@@ -1,6 +1,7 @@
1 import { query } from './_generated/server';1 import { query } from './_generated/server';
2 import { authQuery } from './auth';2 import { authQuery } from './auth';
3 import { v } from 'convex/values';3 import { v } from 'convex/values';
4+import { toPublicUser } from './aim';
4 5
5 // Timeout threshold for marking users as offline (2 minutes)6 // Timeout threshold for marking users as offline (2 minutes)
6 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;7 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;
@@ -122,13 +123,7 @@ export const getUsersPublic = query({
122 const users = await ctx.db.query('users').collect();123 const users = await ctx.db.query('users').collect();
123 124
124 // Return safe user data (no passwords) with computed status125 // Return safe user data (no passwords) with computed status
125- return users.map((user) => ({126+ return users.map(toPublicUser);
126- id: user._id,
127- nickname: user.nickname,
128- status: computeEffectiveStatus(user.status, user.lastSeen),
129- avatarUrl: user.avatarUrl,
130- lastSeen: user.lastSeen
131- }));
132 }127 }
133 });128 });
134 129
@@ -219,13 +214,7 @@ export const getUsers = authQuery({
219 const users = await ctx.db.query('users').collect();214 const users = await ctx.db.query('users').collect();
220 215
221 // Return safe user data (no passwords) with computed status216 // Return safe user data (no passwords) with computed status
222- return users.map((user) => ({217+ return users.map(toPublicUser);
223- id: user._id,
224- nickname: user.nickname,
225- status: computeEffectiveStatus(user.status, user.lastSeen),
226- avatarUrl: user.avatarUrl,
227- lastSeen: user.lastSeen
228- }));
229 }218 }
230 });219 });
231 220
M src/convex/schema.ts +43 -5
@@ -5,11 +5,15 @@ export default defineSchema({
55 users: defineTable({
66 password: v.string(),
77 nickname: v.string(),
8- status: v.string(), // 'offline' | 'online' | 'away' | 'busy'
8+ status: v.string(), // 'offline' | 'online' | 'away' | 'busy' | 'idle'
99 avatarUrl: v.optional(v.string()),
1010 createdAt: v.number(),
1111 lastSeen: v.optional(v.number()),
12- lastReadMentionTimestamp: v.optional(v.number()) // Timestamp of last read mention
12+ lastReadMentionTimestamp: v.optional(v.number()), // Timestamp of last read mention
13+ // AIM profile
14+ awayMessage: v.optional(v.string()), // Shown while status === 'away'
15+ profile: v.optional(v.string()), // Free text "Get Info" profile
16+ buddyIcon: v.optional(v.string()) // Preset icon id (see $lib/aim/buddy-icons)
1317 })
1418 .index('by_nickname', ['nickname'])
1519 .index('by_status', ['status']),
@@ -26,14 +30,20 @@ export default defineSchema({
2630 chatRooms: defineTable({
2731 name: v.optional(v.string()),
2832 type: v.string(), // 'direct' | 'group'
29- createdAt: v.number()
30- }),
33+ createdAt: v.number(),
34+ createdBy: v.optional(v.id('users')),
35+ topic: v.optional(v.string()),
36+ // Direct rooms: exactly two members. Group rooms: undefined (open to all).
37+ memberIds: v.optional(v.array(v.id('users'))),
38+ // Stable key for direct rooms: sorted user ids joined by ':'
39+ directKey: v.optional(v.string())
40+ }).index('by_directKey', ['directKey']),
3141
3242 messages: defineTable({
3343 chatRoomId: v.id('chatRooms'),
3444 senderId: v.id('users'),
3545 content: v.string(),
36- type: v.string(), // 'chat' | 'emote' | 'system'
46+ type: v.string(), // 'chat' | 'emote' | 'system' | 'auto-response'
3747 timestamp: v.number(),
3848 styleData: v.optional(v.string()),
3949 hasFormatting: v.optional(v.boolean())
@@ -41,6 +51,34 @@ export default defineSchema({
4151 .index('by_chatRoom', ['chatRoomId'])
4252 .index('by_timestamp', ['timestamp']),
4353
54+ // AIM buddy list: one row per (owner, buddy)
55+ buddies: defineTable({
56+ userId: v.id('users'),
57+ buddyId: v.id('users'),
58+ group: v.string(), // 'Buddies' | 'Family' | 'Co-Workers' | custom
59+ createdAt: v.number()
60+ })
61+ .index('by_user', ['userId'])
62+ .index('by_user_buddy', ['userId', 'buddyId']),
63+
64+ // Per-user read cursor per room (unread badges)
65+ roomReads: defineTable({
66+ userId: v.id('users'),
67+ roomId: v.id('chatRooms'),
68+ lastReadTimestamp: v.number()
69+ })
70+ .index('by_user', ['userId'])
71+ .index('by_user_room', ['userId', 'roomId']),
72+
73+ // Typing indicators (short-lived, filtered by timestamp at read time)
74+ typing: defineTable({
75+ userId: v.id('users'),
76+ roomId: v.id('chatRooms'),
77+ updatedAt: v.number()
78+ })
79+ .index('by_room', ['roomId'])
80+ .index('by_user_room', ['userId', 'roomId']),
81+
4482 userTextPreferences: defineTable({
4583 userId: v.id('users'),
4684 defaultStyle: v.object({
@@ -5,11 +5,15 @@ export default defineSchema({
5 users: defineTable({5 users: defineTable({
6 password: v.string(),6 password: v.string(),
7 nickname: v.string(),7 nickname: v.string(),
8- status: v.string(), // 'offline' | 'online' | 'away' | 'busy'8+ status: v.string(), // 'offline' | 'online' | 'away' | 'busy' | 'idle'
9 avatarUrl: v.optional(v.string()),9 avatarUrl: v.optional(v.string()),
10 createdAt: v.number(),10 createdAt: v.number(),
11 lastSeen: v.optional(v.number()),11 lastSeen: v.optional(v.number()),
12- lastReadMentionTimestamp: v.optional(v.number()) // Timestamp of last read mention12+ lastReadMentionTimestamp: v.optional(v.number()), // Timestamp of last read mention
13+ // AIM profile
14+ awayMessage: v.optional(v.string()), // Shown while status === 'away'
15+ profile: v.optional(v.string()), // Free text "Get Info" profile
16+ buddyIcon: v.optional(v.string()) // Preset icon id (see $lib/aim/buddy-icons)
13 })17 })
14 .index('by_nickname', ['nickname'])18 .index('by_nickname', ['nickname'])
15 .index('by_status', ['status']),19 .index('by_status', ['status']),
@@ -26,14 +30,20 @@ export default defineSchema({
26 chatRooms: defineTable({30 chatRooms: defineTable({
27 name: v.optional(v.string()),31 name: v.optional(v.string()),
28 type: v.string(), // 'direct' | 'group'32 type: v.string(), // 'direct' | 'group'
29- createdAt: v.number()33+ createdAt: v.number(),
30- }),34+ createdBy: v.optional(v.id('users')),
35+ topic: v.optional(v.string()),
36+ // Direct rooms: exactly two members. Group rooms: undefined (open to all).
37+ memberIds: v.optional(v.array(v.id('users'))),
38+ // Stable key for direct rooms: sorted user ids joined by ':'
39+ directKey: v.optional(v.string())
40+ }).index('by_directKey', ['directKey']),
31 41
32 messages: defineTable({42 messages: defineTable({
33 chatRoomId: v.id('chatRooms'),43 chatRoomId: v.id('chatRooms'),
34 senderId: v.id('users'),44 senderId: v.id('users'),
35 content: v.string(),45 content: v.string(),
36- type: v.string(), // 'chat' | 'emote' | 'system'46+ type: v.string(), // 'chat' | 'emote' | 'system' | 'auto-response'
37 timestamp: v.number(),47 timestamp: v.number(),
38 styleData: v.optional(v.string()),48 styleData: v.optional(v.string()),
39 hasFormatting: v.optional(v.boolean())49 hasFormatting: v.optional(v.boolean())
@@ -41,6 +51,34 @@ export default defineSchema({
41 .index('by_chatRoom', ['chatRoomId'])51 .index('by_chatRoom', ['chatRoomId'])
42 .index('by_timestamp', ['timestamp']),52 .index('by_timestamp', ['timestamp']),
43 53
54+ // AIM buddy list: one row per (owner, buddy)
55+ buddies: defineTable({
56+ userId: v.id('users'),
57+ buddyId: v.id('users'),
58+ group: v.string(), // 'Buddies' | 'Family' | 'Co-Workers' | custom
59+ createdAt: v.number()
60+ })
61+ .index('by_user', ['userId'])
62+ .index('by_user_buddy', ['userId', 'buddyId']),
63+
64+ // Per-user read cursor per room (unread badges)
65+ roomReads: defineTable({
66+ userId: v.id('users'),
67+ roomId: v.id('chatRooms'),
68+ lastReadTimestamp: v.number()
69+ })
70+ .index('by_user', ['userId'])
71+ .index('by_user_room', ['userId', 'roomId']),
72+
73+ // Typing indicators (short-lived, filtered by timestamp at read time)
74+ typing: defineTable({
75+ userId: v.id('users'),
76+ roomId: v.id('chatRooms'),
77+ updatedAt: v.number()
78+ })
79+ .index('by_room', ['roomId'])
80+ .index('by_user_room', ['userId', 'roomId']),
81+
44 userTextPreferences: defineTable({82 userTextPreferences: defineTable({
45 userId: v.id('users'),83 userId: v.id('users'),
46 defaultStyle: v.object({84 defaultStyle: v.object({
A src/lib/aim/buddy-icons.ts +35 -0
@@ -0,0 +1,35 @@
1+/**
2+ * Preset buddy icons (48x48 in the original AIM). Rendered as emoji on a
3+ * coloured tile so no image assets are needed.
4+ */
5+export interface BuddyIconPreset {
6+ id: string;
7+ emoji: string;
8+ label: string;
9+ bg: string;
10+}
11+
12+export const BUDDY_ICONS: BuddyIconPreset[] = [
13+ { id: 'runner', emoji: '🏃', label: 'Running man', bg: '#f6c700' },
14+ { id: 'smiley', emoji: '🙂', label: 'Smiley', bg: '#ffe680' },
15+ { id: 'cool', emoji: '😎', label: 'Cool', bg: '#8fd3ff' },
16+ { id: 'cat', emoji: '🐱', label: 'Chat', bg: '#ffd1dc' },
17+ { id: 'dog', emoji: '🐶', label: 'Chien', bg: '#d9c3a5' },
18+ { id: 'alien', emoji: '👽', label: 'Alien', bg: '#b6ffb0' },
19+ { id: 'skull', emoji: '💀', label: 'Crâne', bg: '#dcdcdc' },
20+ { id: 'fire', emoji: '🔥', label: 'Feu', bg: '#ffb27a' },
21+ { id: 'star', emoji: '⭐', label: 'Étoile', bg: '#3a4fbf' },
22+ { id: 'heart', emoji: '💜', label: 'Cœur', bg: '#f7c6ff' },
23+ { id: 'music', emoji: '🎵', label: 'Musique', bg: '#c4f0ff' },
24+ { id: 'game', emoji: '🎮', label: 'Jeux', bg: '#c9ffd6' },
25+ { id: 'pizza', emoji: '🍕', label: 'Pizza', bg: '#ffe0b3' },
26+ { id: 'rocket', emoji: '🚀', label: 'Fusée', bg: '#1f2a5a' },
27+ { id: 'ghost', emoji: '👻', label: 'Fantôme', bg: '#eaeaff' },
28+ { id: 'robot', emoji: '🤖', label: 'Robot', bg: '#cfd8dc' }
29+];
30+
31+export const DEFAULT_BUDDY_ICON = BUDDY_ICONS[0];
32+
33+export function getBuddyIcon(id: string | null | undefined): BuddyIconPreset {
34+ return BUDDY_ICONS.find((i) => i.id === id) ?? DEFAULT_BUDDY_ICON;
35+}
@@ -0,0 +1,35 @@
1+/**
2+ * Preset buddy icons (48x48 in the original AIM). Rendered as emoji on a
3+ * coloured tile so no image assets are needed.
4+ */
5+export interface BuddyIconPreset {
6+ id: string;
7+ emoji: string;
8+ label: string;
9+ bg: string;
10+}
11+
12+export const BUDDY_ICONS: BuddyIconPreset[] = [
13+ { id: 'runner', emoji: '🏃', label: 'Running man', bg: '#f6c700' },
14+ { id: 'smiley', emoji: '🙂', label: 'Smiley', bg: '#ffe680' },
15+ { id: 'cool', emoji: '😎', label: 'Cool', bg: '#8fd3ff' },
16+ { id: 'cat', emoji: '🐱', label: 'Chat', bg: '#ffd1dc' },
17+ { id: 'dog', emoji: '🐶', label: 'Chien', bg: '#d9c3a5' },
18+ { id: 'alien', emoji: '👽', label: 'Alien', bg: '#b6ffb0' },
19+ { id: 'skull', emoji: '💀', label: 'Crâne', bg: '#dcdcdc' },
20+ { id: 'fire', emoji: '🔥', label: 'Feu', bg: '#ffb27a' },
21+ { id: 'star', emoji: '⭐', label: 'Étoile', bg: '#3a4fbf' },
22+ { id: 'heart', emoji: '💜', label: 'Cœur', bg: '#f7c6ff' },
23+ { id: 'music', emoji: '🎵', label: 'Musique', bg: '#c4f0ff' },
24+ { id: 'game', emoji: '🎮', label: 'Jeux', bg: '#c9ffd6' },
25+ { id: 'pizza', emoji: '🍕', label: 'Pizza', bg: '#ffe0b3' },
26+ { id: 'rocket', emoji: '🚀', label: 'Fusée', bg: '#1f2a5a' },
27+ { id: 'ghost', emoji: '👻', label: 'Fantôme', bg: '#eaeaff' },
28+ { id: 'robot', emoji: '🤖', label: 'Robot', bg: '#cfd8dc' }
29+];
30+
31+export const DEFAULT_BUDDY_ICON = BUDDY_ICONS[0];
32+
33+export function getBuddyIcon(id: string | null | undefined): BuddyIconPreset {
34+ return BUDDY_ICONS.find((i) => i.id === id) ?? DEFAULT_BUDDY_ICON;
35+}
A src/lib/aim/client.ts +90 -0
@@ -0,0 +1,90 @@
1+/**
2+ * Client-side helpers for authenticated Convex calls (AIM features).
3+ *
4+ * Every authQuery/authMutation needs `tokenHash`; this module reads it from
5+ * chatState so components only pass their own args.
6+ */
7+import { useConvexClient, useQuery } from 'convex-svelte';
8+import { api } from '../../convex/_generated/api';
9+import type { FunctionReference, FunctionArgs } from 'convex/server';
10+import type { Id } from '../../convex/_generated/dataModel';
11+import { chatState } from '$lib/states/chat.svelte';
12+import { browser } from '$app/environment';
13+import { env } from '$env/dynamic/public';
14+
15+export const convexAvailable = browser && Boolean(env.PUBLIC_CONVEX_URL);
16+
17+/** Returns the current tokenHash or null when signed out. Reactive when read inside $derived. */
18+export function tokenHash() {
19+ return chatState.getSessionTokenHash();
20+}
21+
22+/**
23+ * Subscribe to an authenticated query. Must be called at component init
24+ * (top level of <script>), like useQuery. The subscription is skipped while
25+ * signed out and resumes automatically after login (data is undefined meanwhile).
26+ *
27+ * const buddies = authQuery(api.aim.getMyBuddies, () => ({}));
28+ * buddies?.data
29+ */
30+export function authQuery<Q extends FunctionReference<'query'>>(
31+ fn: Q,
32+ args: () => Omit<FunctionArgs<Q>, 'tokenHash'> | 'skip' = () => ({}) as Omit<
33+ FunctionArgs<Q>,
34+ 'tokenHash'
35+ >
36+) {
37+ if (!convexAvailable) return null;
38+ return useQuery(fn, () => {
39+ const hash = tokenHash();
40+ const a = args();
41+ if (!hash || a === 'skip') return 'skip';
42+ return { tokenHash: hash, ...a } as FunctionArgs<Q>;
43+ });
44+}
45+
46+export function useAimClient() {
47+ const client = convexAvailable ? useConvexClient() : null;
48+
49+ function requireAuth() {
50+ const hash = tokenHash();
51+ if (!client || !hash) throw new Error('Non connecté');
52+ return { client, hash };
53+ }
54+
55+ return {
56+ client,
57+ async addBuddy(buddyId: Id<'users'>, group?: string) {
58+ const { client, hash } = requireAuth();
59+ return client.mutation(api.aim.addBuddy, { tokenHash: hash, buddyId, group });
60+ },
61+ async removeBuddy(buddyId: Id<'users'>) {
62+ const { client, hash } = requireAuth();
63+ return client.mutation(api.aim.removeBuddy, { tokenHash: hash, buddyId });
64+ },
65+ async getOrCreateDirectRoom(otherUserId: Id<'users'>) {
66+ const { client, hash } = requireAuth();
67+ return client.mutation(api.aim.getOrCreateDirectRoom, { tokenHash: hash, otherUserId });
68+ },
69+ async createGroupRoom(name: string, topic?: string) {
70+ const { client, hash } = requireAuth();
71+ return client.mutation(api.aim.createGroupRoom, { tokenHash: hash, name, topic });
72+ },
73+ async setAwayMessage(awayMessage?: string) {
74+ const { client, hash } = requireAuth();
75+ return client.mutation(api.aim.setAwayMessage, { tokenHash: hash, awayMessage });
76+ },
77+ async updateProfile(patch: { profile?: string; buddyIcon?: string }) {
78+ const { client, hash } = requireAuth();
79+ return client.mutation(api.aim.updateProfile, { tokenHash: hash, ...patch });
80+ },
81+ async markRoomRead(roomId: Id<'chatRooms'>) {
82+ const { client, hash } = requireAuth();
83+ return client.mutation(api.aim.markRoomRead, { tokenHash: hash, roomId });
84+ },
85+ async setTyping(roomId: Id<'chatRooms'>, typing: boolean) {
86+ const { client, hash } = requireAuth();
87+ return client.mutation(api.aim.setTyping, { tokenHash: hash, roomId, typing });
88+ }
89+ };
90+}
@@ -0,0 +1,90 @@
1+/**
2+ * Client-side helpers for authenticated Convex calls (AIM features).
3+ *
4+ * Every authQuery/authMutation needs `tokenHash`; this module reads it from
5+ * chatState so components only pass their own args.
6+ */
7+import { useConvexClient, useQuery } from 'convex-svelte';
8+import { api } from '../../convex/_generated/api';
9+import type { FunctionReference, FunctionArgs } from 'convex/server';
10+import type { Id } from '../../convex/_generated/dataModel';
11+import { chatState } from '$lib/states/chat.svelte';
12+import { browser } from '$app/environment';
13+import { env } from '$env/dynamic/public';
14+
15+export const convexAvailable = browser && Boolean(env.PUBLIC_CONVEX_URL);
16+
17+/** Returns the current tokenHash or null when signed out. Reactive when read inside $derived. */
18+export function tokenHash() {
19+ return chatState.getSessionTokenHash();
20+}
21+
22+/**
23+ * Subscribe to an authenticated query. Must be called at component init
24+ * (top level of <script>), like useQuery. The subscription is skipped while
25+ * signed out and resumes automatically after login (data is undefined meanwhile).
26+ *
27+ * const buddies = authQuery(api.aim.getMyBuddies, () => ({}));
28+ * buddies?.data
29+ */
30+export function authQuery<Q extends FunctionReference<'query'>>(
31+ fn: Q,
32+ args: () => Omit<FunctionArgs<Q>, 'tokenHash'> | 'skip' = () => ({}) as Omit<
33+ FunctionArgs<Q>,
34+ 'tokenHash'
35+ >
36+) {
37+ if (!convexAvailable) return null;
38+ return useQuery(fn, () => {
39+ const hash = tokenHash();
40+ const a = args();
41+ if (!hash || a === 'skip') return 'skip';
42+ return { tokenHash: hash, ...a } as FunctionArgs<Q>;
43+ });
44+}
45+
46+export function useAimClient() {
47+ const client = convexAvailable ? useConvexClient() : null;
48+
49+ function requireAuth() {
50+ const hash = tokenHash();
51+ if (!client || !hash) throw new Error('Non connecté');
52+ return { client, hash };
53+ }
54+
55+ return {
56+ client,
57+ async addBuddy(buddyId: Id<'users'>, group?: string) {
58+ const { client, hash } = requireAuth();
59+ return client.mutation(api.aim.addBuddy, { tokenHash: hash, buddyId, group });
60+ },
61+ async removeBuddy(buddyId: Id<'users'>) {
62+ const { client, hash } = requireAuth();
63+ return client.mutation(api.aim.removeBuddy, { tokenHash: hash, buddyId });
64+ },
65+ async getOrCreateDirectRoom(otherUserId: Id<'users'>) {
66+ const { client, hash } = requireAuth();
67+ return client.mutation(api.aim.getOrCreateDirectRoom, { tokenHash: hash, otherUserId });
68+ },
69+ async createGroupRoom(name: string, topic?: string) {
70+ const { client, hash } = requireAuth();
71+ return client.mutation(api.aim.createGroupRoom, { tokenHash: hash, name, topic });
72+ },
73+ async setAwayMessage(awayMessage?: string) {
74+ const { client, hash } = requireAuth();
75+ return client.mutation(api.aim.setAwayMessage, { tokenHash: hash, awayMessage });
76+ },
77+ async updateProfile(patch: { profile?: string; buddyIcon?: string }) {
78+ const { client, hash } = requireAuth();
79+ return client.mutation(api.aim.updateProfile, { tokenHash: hash, ...patch });
80+ },
81+ async markRoomRead(roomId: Id<'chatRooms'>) {
82+ const { client, hash } = requireAuth();
83+ return client.mutation(api.aim.markRoomRead, { tokenHash: hash, roomId });
84+ },
85+ async setTyping(roomId: Id<'chatRooms'>, typing: boolean) {
86+ const { client, hash } = requireAuth();
87+ return client.mutation(api.aim.setTyping, { tokenHash: hash, roomId, typing });
88+ }
89+ };
90+}
A src/lib/aim/sounds.ts +227 -0
@@ -0,0 +1,227 @@
1+/**
2+ * AIM sound effects synthesized with the Web Audio API.
3+ *
4+ * No audio files are shipped: everything is generated procedurally so the
5+ * project stays free of copyrighted samples. Each sound is a small sketch
6+ * evoking the original:
7+ * - doorOpen : "buddy in" — creak + soft thud
8+ * - doorSlam : "buddy out" — sharp slam
9+ * - imReceive : incoming IM "ding"
10+ * - imSend : outgoing IM "tick"
11+ * - welcome : sign-on chime
12+ * - modem : dial-up handshake (long, for the sign-on screen)
13+ */
14+import { browser } from '$app/environment';
15+
16+export type AimSound = 'doorOpen' | 'doorSlam' | 'imReceive' | 'imSend' | 'welcome' | 'modem';
17+
18+const STORAGE_KEY = 'pdraim-sounds-enabled';
19+
20+let ctx: AudioContext | null = null;
21+let enabled = true;
22+
23+if (browser) {
24+ const stored = localStorage.getItem(STORAGE_KEY);
25+ enabled = stored === null ? true : stored === 'true';
26+}
27+
28+export function soundsEnabled() {
29+ return enabled;
30+}
31+
32+export function setSoundsEnabled(value: boolean) {
33+ enabled = value;
34+ if (browser) localStorage.setItem(STORAGE_KEY, String(value));
35+}
36+
37+function getContext(): AudioContext | null {
38+ if (!browser) return null;
39+ if (!ctx) {
40+ try {
41+ ctx = new AudioContext();
42+ } catch {
43+ return null;
44+ }
45+ }
46+ if (ctx.state === 'suspended') void ctx.resume();
47+ return ctx;
48+}
49+
50+/** Call once from a user gesture so the browser allows playback. */
51+export function unlockAudio() {
52+ getContext();
53+}
54+
55+function noiseBuffer(ac: AudioContext, seconds: number) {
56+ const buffer = ac.createBuffer(1, Math.floor(ac.sampleRate * seconds), ac.sampleRate);
57+ const data = buffer.getChannelData(0);
58+ for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
59+ return buffer;
60+}
61+
62+function tone(
63+ ac: AudioContext,
64+ dest: AudioNode,
65+ opts: {
66+ type?: OscillatorType;
67+ freq: number;
68+ freqEnd?: number;
69+ start: number;
70+ duration: number;
71+ gain?: number;
72+ attack?: number;
73+ }
74+) {
75+ const osc = ac.createOscillator();
76+ const g = ac.createGain();
77+ osc.type = opts.type ?? 'sine';
78+ osc.frequency.setValueAtTime(opts.freq, opts.start);
79+ if (opts.freqEnd) {
80+ osc.frequency.exponentialRampToValueAtTime(opts.freqEnd, opts.start + opts.duration);
81+ }
82+ const peak = opts.gain ?? 0.3;
83+ const attack = opts.attack ?? 0.005;
84+ g.gain.setValueAtTime(0.0001, opts.start);
85+ g.gain.exponentialRampToValueAtTime(peak, opts.start + attack);
86+ g.gain.exponentialRampToValueAtTime(0.0001, opts.start + opts.duration);
87+ osc.connect(g).connect(dest);
88+ osc.start(opts.start);
89+ osc.stop(opts.start + opts.duration + 0.05);
90+}
91+
92+function thud(ac: AudioContext, dest: AudioNode, start: number, gain = 0.8, duration = 0.18) {
93+ // Low body knock: filtered noise burst + pitched sine drop
94+ const src = ac.createBufferSource();
95+ src.buffer = noiseBuffer(ac, duration);
96+ const filter = ac.createBiquadFilter();
97+ filter.type = 'lowpass';
98+ filter.frequency.setValueAtTime(900, start);
99+ filter.frequency.exponentialRampToValueAtTime(120, start + duration);
100+ const g = ac.createGain();
101+ g.gain.setValueAtTime(gain, start);
102+ g.gain.exponentialRampToValueAtTime(0.0001, start + duration);
103+ src.connect(filter).connect(g).connect(dest);
104+ src.start(start);
105+ tone(ac, dest, {
106+ freq: 160,
107+ freqEnd: 45,
108+ start,
109+ duration: duration * 0.9,
110+ gain: gain * 0.7,
111+ attack: 0.002
112+ });
113+}
114+
115+function creak(ac: AudioContext, dest: AudioNode, start: number, duration = 0.35) {
116+ // Hinge creak: sawtooth wobble with resonant bandpass
117+ const osc = ac.createOscillator();
118+ osc.type = 'sawtooth';
119+ osc.frequency.setValueAtTime(420, start);
120+ osc.frequency.linearRampToValueAtTime(560, start + duration * 0.6);
121+ osc.frequency.linearRampToValueAtTime(380, start + duration);
122+ const lfo = ac.createOscillator();
123+ lfo.frequency.value = 28;
124+ const lfoGain = ac.createGain();
125+ lfoGain.gain.value = 60;
126+ lfo.connect(lfoGain).connect(osc.frequency);
127+ const filter = ac.createBiquadFilter();
128+ filter.type = 'bandpass';
129+ filter.frequency.value = 1400;
130+ filter.Q.value = 6;
131+ const g = ac.createGain();
132+ g.gain.setValueAtTime(0.0001, start);
133+ g.gain.exponentialRampToValueAtTime(0.12, start + 0.04);
134+ g.gain.exponentialRampToValueAtTime(0.0001, start + duration);
135+ osc.connect(filter).connect(g).connect(dest);
136+ osc.start(start);
137+ lfo.start(start);
138+ osc.stop(start + duration + 0.05);
139+ lfo.stop(start + duration + 0.05);
140+}
141+
142+const players: Record<AimSound, (ac: AudioContext, dest: AudioNode, t: number) => void> = {
143+ doorOpen(ac, dest, t) {
144+ creak(ac, dest, t, 0.32);
145+ thud(ac, dest, t + 0.3, 0.5, 0.14);
146+ },
147+ doorSlam(ac, dest, t) {
148+ thud(ac, dest, t, 1.0, 0.22);
149+ // door frame rattle
150+ tone(ac, dest, { type: 'square', freq: 90, freqEnd: 60, start: t + 0.02, duration: 0.12, gain: 0.12 });
151+ },
152+ imReceive(ac, dest, t) {
153+ // Two-note ding, bright
154+ tone(ac, dest, { type: 'triangle', freq: 1046, start: t, duration: 0.18, gain: 0.25 });
155+ tone(ac, dest, { type: 'sine', freq: 1568, start: t + 0.06, duration: 0.32, gain: 0.2 });
156+ },
157+ imSend(ac, dest, t) {
158+ tone(ac, dest, { type: 'triangle', freq: 880, start: t, duration: 0.09, gain: 0.18 });
159+ },
160+ welcome(ac, dest, t) {
161+ // Ascending three-note chime
162+ tone(ac, dest, { freq: 523, start: t, duration: 0.25, gain: 0.22 });
163+ tone(ac, dest, { freq: 659, start: t + 0.14, duration: 0.25, gain: 0.22 });
164+ tone(ac, dest, { freq: 784, start: t + 0.28, duration: 0.5, gain: 0.25 });
165+ },
166+ modem(ac, dest, t) {
167+ // Dial tone
168+ tone(ac, dest, { freq: 350, start: t, duration: 0.5, gain: 0.08 });
169+ tone(ac, dest, { freq: 440, start: t, duration: 0.5, gain: 0.08 });
170+ // DTMF-ish dialing
171+ const digits = [
172+ [697, 1209],
173+ [770, 1336],
174+ [852, 1477],
175+ [941, 1336],
176+ [697, 1477],
177+ [770, 1209],
178+ [852, 1336]
179+ ];
180+ digits.forEach(([lo, hi], i) => {
181+ const s = t + 0.6 + i * 0.13;
182+ tone(ac, dest, { freq: lo, start: s, duration: 0.09, gain: 0.08 });
183+ tone(ac, dest, { freq: hi, start: s, duration: 0.09, gain: 0.08 });
184+ });
185+ // Handshake: answer tone, then chirps
186+ const h = t + 1.7;
187+ tone(ac, dest, { freq: 2100, start: h, duration: 0.6, gain: 0.07 });
188+ tone(ac, dest, { type: 'square', freq: 1200, freqEnd: 2400, start: h + 0.7, duration: 0.35, gain: 0.05 });
189+ tone(ac, dest, { type: 'square', freq: 2400, freqEnd: 1200, start: h + 1.1, duration: 0.35, gain: 0.05 });
190+ // Noise burst (training)
191+ const src = ac.createBufferSource();
192+ src.buffer = noiseBuffer(ac, 1.2);
193+ const filter = ac.createBiquadFilter();
194+ filter.type = 'bandpass';
195+ filter.frequency.value = 1800;
196+ filter.Q.value = 0.7;
197+ const g = ac.createGain();
198+ g.gain.setValueAtTime(0.0001, h + 1.5);
199+ g.gain.exponentialRampToValueAtTime(0.09, h + 1.6);
200+ g.gain.exponentialRampToValueAtTime(0.0001, h + 2.7);
201+ src.connect(filter).connect(g).connect(dest);
202+ src.start(h + 1.5);
203+ }
204+};
205+
206+export const SOUND_DURATIONS_MS: Record<AimSound, number> = {
207+ doorOpen: 500,
208+ doorSlam: 300,
209+ imReceive: 400,
210+ imSend: 120,
211+ welcome: 800,
212+ modem: 4500
213+};
214+
215+export function playSound(name: AimSound) {
216+ if (!enabled) return;
217+ const ac = getContext();
218+ if (!ac) return;
219+ const master = ac.createGain();
220+ master.gain.value = 0.9;
221+ master.connect(ac.destination);
222+ try {
223+ players[name](ac, master, ac.currentTime);
224+ } catch (err) {
225+ console.warn('Sound playback failed', err);
226+ }
227+}
@@ -0,0 +1,227 @@
1+/**
2+ * AIM sound effects synthesized with the Web Audio API.
3+ *
4+ * No audio files are shipped: everything is generated procedurally so the
5+ * project stays free of copyrighted samples. Each sound is a small sketch
6+ * evoking the original:
7+ * - doorOpen : "buddy in" — creak + soft thud
8+ * - doorSlam : "buddy out" — sharp slam
9+ * - imReceive : incoming IM "ding"
10+ * - imSend : outgoing IM "tick"
11+ * - welcome : sign-on chime
12+ * - modem : dial-up handshake (long, for the sign-on screen)
13+ */
14+import { browser } from '$app/environment';
15+
16+export type AimSound = 'doorOpen' | 'doorSlam' | 'imReceive' | 'imSend' | 'welcome' | 'modem';
17+
18+const STORAGE_KEY = 'pdraim-sounds-enabled';
19+
20+let ctx: AudioContext | null = null;
21+let enabled = true;
22+
23+if (browser) {
24+ const stored = localStorage.getItem(STORAGE_KEY);
25+ enabled = stored === null ? true : stored === 'true';
26+}
27+
28+export function soundsEnabled() {
29+ return enabled;
30+}
31+
32+export function setSoundsEnabled(value: boolean) {
33+ enabled = value;
34+ if (browser) localStorage.setItem(STORAGE_KEY, String(value));
35+}
36+
37+function getContext(): AudioContext | null {
38+ if (!browser) return null;
39+ if (!ctx) {
40+ try {
41+ ctx = new AudioContext();
42+ } catch {
43+ return null;
44+ }
45+ }
46+ if (ctx.state === 'suspended') void ctx.resume();
47+ return ctx;
48+}
49+
50+/** Call once from a user gesture so the browser allows playback. */
51+export function unlockAudio() {
52+ getContext();
53+}
54+
55+function noiseBuffer(ac: AudioContext, seconds: number) {
56+ const buffer = ac.createBuffer(1, Math.floor(ac.sampleRate * seconds), ac.sampleRate);
57+ const data = buffer.getChannelData(0);
58+ for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
59+ return buffer;
60+}
61+
62+function tone(
63+ ac: AudioContext,
64+ dest: AudioNode,
65+ opts: {
66+ type?: OscillatorType;
67+ freq: number;
68+ freqEnd?: number;
69+ start: number;
70+ duration: number;
71+ gain?: number;
72+ attack?: number;
73+ }
74+) {
75+ const osc = ac.createOscillator();
76+ const g = ac.createGain();
77+ osc.type = opts.type ?? 'sine';
78+ osc.frequency.setValueAtTime(opts.freq, opts.start);
79+ if (opts.freqEnd) {
80+ osc.frequency.exponentialRampToValueAtTime(opts.freqEnd, opts.start + opts.duration);
81+ }
82+ const peak = opts.gain ?? 0.3;
83+ const attack = opts.attack ?? 0.005;
84+ g.gain.setValueAtTime(0.0001, opts.start);
85+ g.gain.exponentialRampToValueAtTime(peak, opts.start + attack);
86+ g.gain.exponentialRampToValueAtTime(0.0001, opts.start + opts.duration);
87+ osc.connect(g).connect(dest);
88+ osc.start(opts.start);
89+ osc.stop(opts.start + opts.duration + 0.05);
90+}
91+
92+function thud(ac: AudioContext, dest: AudioNode, start: number, gain = 0.8, duration = 0.18) {
93+ // Low body knock: filtered noise burst + pitched sine drop
94+ const src = ac.createBufferSource();
95+ src.buffer = noiseBuffer(ac, duration);
96+ const filter = ac.createBiquadFilter();
97+ filter.type = 'lowpass';
98+ filter.frequency.setValueAtTime(900, start);
99+ filter.frequency.exponentialRampToValueAtTime(120, start + duration);
100+ const g = ac.createGain();
101+ g.gain.setValueAtTime(gain, start);
102+ g.gain.exponentialRampToValueAtTime(0.0001, start + duration);
103+ src.connect(filter).connect(g).connect(dest);
104+ src.start(start);
105+ tone(ac, dest, {
106+ freq: 160,
107+ freqEnd: 45,
108+ start,
109+ duration: duration * 0.9,
110+ gain: gain * 0.7,
111+ attack: 0.002
112+ });
113+}
114+
115+function creak(ac: AudioContext, dest: AudioNode, start: number, duration = 0.35) {
116+ // Hinge creak: sawtooth wobble with resonant bandpass
117+ const osc = ac.createOscillator();
118+ osc.type = 'sawtooth';
119+ osc.frequency.setValueAtTime(420, start);
120+ osc.frequency.linearRampToValueAtTime(560, start + duration * 0.6);
121+ osc.frequency.linearRampToValueAtTime(380, start + duration);
122+ const lfo = ac.createOscillator();
123+ lfo.frequency.value = 28;
124+ const lfoGain = ac.createGain();
125+ lfoGain.gain.value = 60;
126+ lfo.connect(lfoGain).connect(osc.frequency);
127+ const filter = ac.createBiquadFilter();
128+ filter.type = 'bandpass';
129+ filter.frequency.value = 1400;
130+ filter.Q.value = 6;
131+ const g = ac.createGain();
132+ g.gain.setValueAtTime(0.0001, start);
133+ g.gain.exponentialRampToValueAtTime(0.12, start + 0.04);
134+ g.gain.exponentialRampToValueAtTime(0.0001, start + duration);
135+ osc.connect(filter).connect(g).connect(dest);
136+ osc.start(start);
137+ lfo.start(start);
138+ osc.stop(start + duration + 0.05);
139+ lfo.stop(start + duration + 0.05);
140+}
141+
142+const players: Record<AimSound, (ac: AudioContext, dest: AudioNode, t: number) => void> = {
143+ doorOpen(ac, dest, t) {
144+ creak(ac, dest, t, 0.32);
145+ thud(ac, dest, t + 0.3, 0.5, 0.14);
146+ },
147+ doorSlam(ac, dest, t) {
148+ thud(ac, dest, t, 1.0, 0.22);
149+ // door frame rattle
150+ tone(ac, dest, { type: 'square', freq: 90, freqEnd: 60, start: t + 0.02, duration: 0.12, gain: 0.12 });
151+ },
152+ imReceive(ac, dest, t) {
153+ // Two-note ding, bright
154+ tone(ac, dest, { type: 'triangle', freq: 1046, start: t, duration: 0.18, gain: 0.25 });
155+ tone(ac, dest, { type: 'sine', freq: 1568, start: t + 0.06, duration: 0.32, gain: 0.2 });
156+ },
157+ imSend(ac, dest, t) {
158+ tone(ac, dest, { type: 'triangle', freq: 880, start: t, duration: 0.09, gain: 0.18 });
159+ },
160+ welcome(ac, dest, t) {
161+ // Ascending three-note chime
162+ tone(ac, dest, { freq: 523, start: t, duration: 0.25, gain: 0.22 });
163+ tone(ac, dest, { freq: 659, start: t + 0.14, duration: 0.25, gain: 0.22 });
164+ tone(ac, dest, { freq: 784, start: t + 0.28, duration: 0.5, gain: 0.25 });
165+ },
166+ modem(ac, dest, t) {
167+ // Dial tone
168+ tone(ac, dest, { freq: 350, start: t, duration: 0.5, gain: 0.08 });
169+ tone(ac, dest, { freq: 440, start: t, duration: 0.5, gain: 0.08 });
170+ // DTMF-ish dialing
171+ const digits = [
172+ [697, 1209],
173+ [770, 1336],
174+ [852, 1477],
175+ [941, 1336],
176+ [697, 1477],
177+ [770, 1209],
178+ [852, 1336]
179+ ];
180+ digits.forEach(([lo, hi], i) => {
181+ const s = t + 0.6 + i * 0.13;
182+ tone(ac, dest, { freq: lo, start: s, duration: 0.09, gain: 0.08 });
183+ tone(ac, dest, { freq: hi, start: s, duration: 0.09, gain: 0.08 });
184+ });
185+ // Handshake: answer tone, then chirps
186+ const h = t + 1.7;
187+ tone(ac, dest, { freq: 2100, start: h, duration: 0.6, gain: 0.07 });
188+ tone(ac, dest, { type: 'square', freq: 1200, freqEnd: 2400, start: h + 0.7, duration: 0.35, gain: 0.05 });
189+ tone(ac, dest, { type: 'square', freq: 2400, freqEnd: 1200, start: h + 1.1, duration: 0.35, gain: 0.05 });
190+ // Noise burst (training)
191+ const src = ac.createBufferSource();
192+ src.buffer = noiseBuffer(ac, 1.2);
193+ const filter = ac.createBiquadFilter();
194+ filter.type = 'bandpass';
195+ filter.frequency.value = 1800;
196+ filter.Q.value = 0.7;
197+ const g = ac.createGain();
198+ g.gain.setValueAtTime(0.0001, h + 1.5);
199+ g.gain.exponentialRampToValueAtTime(0.09, h + 1.6);
200+ g.gain.exponentialRampToValueAtTime(0.0001, h + 2.7);
201+ src.connect(filter).connect(g).connect(dest);
202+ src.start(h + 1.5);
203+ }
204+};
205+
206+export const SOUND_DURATIONS_MS: Record<AimSound, number> = {
207+ doorOpen: 500,
208+ doorSlam: 300,
209+ imReceive: 400,
210+ imSend: 120,
211+ welcome: 800,
212+ modem: 4500
213+};
214+
215+export function playSound(name: AimSound) {
216+ if (!enabled) return;
217+ const ac = getContext();
218+ if (!ac) return;
219+ const master = ac.createGain();
220+ master.gain.value = 0.9;
221+ master.connect(ac.destination);
222+ try {
223+ players[name](ac, master, ac.currentTime);
224+ } catch (err) {
225+ console.warn('Sound playback failed', err);
226+ }
227+}
A src/lib/components/desktop/xp-window.svelte +263 -0
@@ -0,0 +1,263 @@
1+<script lang="ts">
2+ /**
3+ * Generic Windows XP window shell driven by the desktop window manager.
4+ * Handles: drag, optional resize, focus/z-index, minimize/close buttons,
5+ * mobile full-screen fallback. Content goes in the default snippet.
6+ */
7+ import type { Snippet } from 'svelte';
8+ import { onMount } from 'svelte';
9+ import { draggable } from '$lib/actions/draggable';
10+ import { resizable } from '$lib/actions/resizable';
11+ import { desktop } from '$lib/states/desktop.svelte';
12+
13+ let {
14+ id,
15+ width = 420,
16+ height = 320,
17+ minWidth = 240,
18+ minHeight = 160,
19+ x,
20+ y,
21+ resizableWindow = true,
22+ showMaximize = false,
23+ bodyClass = '',
24+ children,
25+ menu,
26+ onClose
27+ } = $props<{
28+ id: string;
29+ width?: number;
30+ height?: number;
31+ minWidth?: number;
32+ minHeight?: number;
33+ x?: number;
34+ y?: number;
35+ resizableWindow?: boolean;
36+ showMaximize?: boolean;
37+ bodyClass?: string;
38+ children: Snippet;
39+ menu?: Snippet;
40+ onClose?: () => void;
41+ }>();
42+
43+ const win = $derived(desktop.get(id));
44+ const focused = $derived(desktop.focusedId === id);
45+
46+ // svelte-ignore state_referenced_locally -- initial geometry only; the user moves/resizes afterwards
47+ let w = $state(width);
48+ // svelte-ignore state_referenced_locally
49+ let h = $state(height);
50+ // svelte-ignore state_referenced_locally
51+ let left = $state(x ?? 0);
52+ // svelte-ignore state_referenced_locally
53+ let top = $state(y ?? 0);
54+ let isMobile = $state(false);
55+ let isMaximized = $state(false);
56+ let restore: { w: number; h: number; left: number; top: number } | null = null;
57+
58+ onMount(() => {
59+ isMobile = window.innerWidth <= 768;
60+ if (x === undefined || y === undefined) {
61+ // Cascade new windows slightly from the centre so they do not stack exactly
62+ const offset = (desktop.windows.length % 6) * 24;
63+ left = Math.max(8, Math.round((window.innerWidth - w) / 2) + offset);
64+ top = Math.max(8, Math.round((window.innerHeight - h) / 2 - 40) + offset);
65+ }
66+ const onResize = () => {
67+ isMobile = window.innerWidth <= 768;
68+ };
69+ window.addEventListener('resize', onResize);
70+ return () => window.removeEventListener('resize', onResize);
71+ });
72+
73+ function close() {
74+ onClose?.();
75+ desktop.close(id);
76+ }
77+
78+ function minimize(e: MouseEvent) {
79+ e.stopPropagation();
80+ desktop.minimize(id);
81+ }
82+
83+ function toggleMaximize(e?: MouseEvent) {
84+ e?.stopPropagation();
85+ if (isMobile) return;
86+ if (isMaximized && restore) {
87+ w = restore.w;
88+ h = restore.h;
89+ left = restore.left;
90+ top = restore.top;
91+ isMaximized = false;
92+ } else {
93+ restore = { w, h, left, top };
94+ left = 4;
95+ top = 4;
96+ w = window.innerWidth - 8;
97+ h = window.innerHeight - 8 - 30; // leave room for the taskbar
98+ isMaximized = true;
99+ }
100+ }
101+</script>
102+
103+{#if win}
104+ <div
105+ class="window xp-window"
106+ class:inactive={!focused}
107+ class:hidden={win.minimized}
108+ class:mobile={isMobile}
109+ style="width: {w}px; height: {h}px; left: {left}px; top: {top}px; z-index: {win.z};"
110+ role="dialog"
111+ aria-label={win.title}
112+ tabindex="-1"
113+ onmousedown={() => desktop.focus(id)}
114+ use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
115+ ondragmove={(e) => {
116+ left = e.detail.x;
117+ top = e.detail.y;
118+ }}
119+ use:resizable={{
120+ enabled: resizableWindow && !isMobile && !isMaximized,
121+ minWidth,
122+ minHeight,
123+ maxWidth: typeof window !== 'undefined' ? window.innerWidth - 16 : 1600,
124+ maxHeight: typeof window !== 'undefined' ? window.innerHeight - 40 : 1200
125+ }}
126+ onresizemove={(e) => {
127+ w = Math.max(minWidth, e.detail.width);
128+ h = Math.max(minHeight, e.detail.height);
129+ }}
130+ >
131+ <div class="title-bar" ondblclick={() => showMaximize && toggleMaximize()} role="presentation">
132+ <div class="title-bar-text">
133+ <img src={win.icon} alt="" width="16" height="16" class="title-icon" />
134+ <span class="title-text">{win.title}</span>
135+ </div>
136+ <div class="title-bar-controls">
137+ <button aria-label="Minimize" onclick={minimize}></button>
138+ {#if showMaximize && !isMobile}
139+ <button
140+ aria-label={isMaximized ? 'Restore' : 'Maximize'}
141+ onclick={toggleMaximize}
142+ ></button>
143+ {/if}
144+ <button aria-label="Close" onclick={close}></button>
145+ </div>
146+ </div>
147+ {#if menu}
148+ <div class="xp-menubar">{@render menu()}</div>
149+ {/if}
150+ <div class="window-body xp-body {bodyClass}" class:has-menu={Boolean(menu)}>
151+ {@render children()}
152+ </div>
153+ </div>
154+{/if}
155+
156+<style>
157+ .xp-window {
158+ position: fixed;
159+ box-sizing: border-box;
160+ display: flex;
161+ flex-direction: column;
162+ font-family: Tahoma, 'Pixelated MS Sans Serif', Arial, sans-serif;
163+ box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.35);
164+ }
165+
166+ .xp-window.hidden {
167+ display: none;
168+ }
169+
170+ .xp-window.inactive {
171+ box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
172+ }
173+
174+ /* xp.css already styles active/inactive gradients through :not(:focus-within);
175+ force the inactive look based on our own focus tracking instead. */
176+ .xp-window.inactive .title-bar {
177+ background: linear-gradient(180deg, #7697e7 0%, #7e9ee3 8%, #94afec 40%, #97b4e9 88%, #82a5e4 100%);
178+ }
179+
180+ .title-bar {
181+ cursor: move;
182+ user-select: none;
183+ display: flex;
184+ align-items: center;
185+ flex: 0 0 auto;
186+ }
187+
188+ .title-bar-text {
189+ display: flex;
190+ align-items: center;
191+ gap: 4px;
192+ white-space: nowrap;
193+ overflow: hidden;
194+ text-overflow: ellipsis;
195+ min-width: 0;
196+ }
197+
198+ .title-text {
199+ overflow: hidden;
200+ text-overflow: ellipsis;
201+ }
202+
203+ .title-icon {
204+ flex: 0 0 16px;
205+ }
206+
207+ .xp-menubar {
208+ display: flex;
209+ gap: 2px;
210+ padding: 1px 4px;
211+ background: #ece9d8;
212+ border-bottom: 1px solid #aca899;
213+ font-size: 11px;
214+ flex: 0 0 auto;
215+ }
216+
217+ .xp-menubar :global(button) {
218+ border: 1px solid transparent;
219+ background: transparent;
220+ box-shadow: none;
221+ min-width: 0;
222+ padding: 2px 6px;
223+ font-size: 11px;
224+ border-radius: 0;
225+ }
226+
227+ .xp-menubar :global(button:hover) {
228+ background: #316ac5;
229+ color: white;
230+ }
231+
232+ .xp-body {
233+ flex: 1 1 auto;
234+ min-height: 0;
235+ display: flex;
236+ flex-direction: column;
237+ margin: 0;
238+ padding: 6px;
239+ overflow: hidden;
240+ }
241+
242+ :global(.xp-window .resize-handle) {
243+ position: absolute;
244+ bottom: 0;
245+ right: 0;
246+ width: 15px !important;
247+ height: 15px !important;
248+ cursor: se-resize !important;
249+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Cpath d='M11 11v-2h2v2h-2zm0-4h2v2h-2V7zm-2 2V7h2v2H9zm0 2v-2h2v2H9zm-2 0v-2h2v2H7z' fill='%23555'/%3E%3C/svg%3E");
250+ background-position: bottom right;
251+ background-repeat: no-repeat;
252+ }
253+
254+ @media (max-width: 768px) {
255+ .xp-window.mobile {
256+ left: 0 !important;
257+ top: 0 !important;
258+ width: 100vw !important;
259+ height: calc(100vh - 30px) !important;
260+ border-radius: 0;
261+ }
262+ }
263+</style>
@@ -0,0 +1,263 @@
1+<script lang="ts">
2+ /**
3+ * Generic Windows XP window shell driven by the desktop window manager.
4+ * Handles: drag, optional resize, focus/z-index, minimize/close buttons,
5+ * mobile full-screen fallback. Content goes in the default snippet.
6+ */
7+ import type { Snippet } from 'svelte';
8+ import { onMount } from 'svelte';
9+ import { draggable } from '$lib/actions/draggable';
10+ import { resizable } from '$lib/actions/resizable';
11+ import { desktop } from '$lib/states/desktop.svelte';
12+
13+ let {
14+ id,
15+ width = 420,
16+ height = 320,
17+ minWidth = 240,
18+ minHeight = 160,
19+ x,
20+ y,
21+ resizableWindow = true,
22+ showMaximize = false,
23+ bodyClass = '',
24+ children,
25+ menu,
26+ onClose
27+ } = $props<{
28+ id: string;
29+ width?: number;
30+ height?: number;
31+ minWidth?: number;
32+ minHeight?: number;
33+ x?: number;
34+ y?: number;
35+ resizableWindow?: boolean;
36+ showMaximize?: boolean;
37+ bodyClass?: string;
38+ children: Snippet;
39+ menu?: Snippet;
40+ onClose?: () => void;
41+ }>();
42+
43+ const win = $derived(desktop.get(id));
44+ const focused = $derived(desktop.focusedId === id);
45+
46+ // svelte-ignore state_referenced_locally -- initial geometry only; the user moves/resizes afterwards
47+ let w = $state(width);
48+ // svelte-ignore state_referenced_locally
49+ let h = $state(height);
50+ // svelte-ignore state_referenced_locally
51+ let left = $state(x ?? 0);
52+ // svelte-ignore state_referenced_locally
53+ let top = $state(y ?? 0);
54+ let isMobile = $state(false);
55+ let isMaximized = $state(false);
56+ let restore: { w: number; h: number; left: number; top: number } | null = null;
57+
58+ onMount(() => {
59+ isMobile = window.innerWidth <= 768;
60+ if (x === undefined || y === undefined) {
61+ // Cascade new windows slightly from the centre so they do not stack exactly
62+ const offset = (desktop.windows.length % 6) * 24;
63+ left = Math.max(8, Math.round((window.innerWidth - w) / 2) + offset);
64+ top = Math.max(8, Math.round((window.innerHeight - h) / 2 - 40) + offset);
65+ }
66+ const onResize = () => {
67+ isMobile = window.innerWidth <= 768;
68+ };
69+ window.addEventListener('resize', onResize);
70+ return () => window.removeEventListener('resize', onResize);
71+ });
72+
73+ function close() {
74+ onClose?.();
75+ desktop.close(id);
76+ }
77+
78+ function minimize(e: MouseEvent) {
79+ e.stopPropagation();
80+ desktop.minimize(id);
81+ }
82+
83+ function toggleMaximize(e?: MouseEvent) {
84+ e?.stopPropagation();
85+ if (isMobile) return;
86+ if (isMaximized && restore) {
87+ w = restore.w;
88+ h = restore.h;
89+ left = restore.left;
90+ top = restore.top;
91+ isMaximized = false;
92+ } else {
93+ restore = { w, h, left, top };
94+ left = 4;
95+ top = 4;
96+ w = window.innerWidth - 8;
97+ h = window.innerHeight - 8 - 30; // leave room for the taskbar
98+ isMaximized = true;
99+ }
100+ }
101+</script>
102+
103+{#if win}
104+ <div
105+ class="window xp-window"
106+ class:inactive={!focused}
107+ class:hidden={win.minimized}
108+ class:mobile={isMobile}
109+ style="width: {w}px; height: {h}px; left: {left}px; top: {top}px; z-index: {win.z};"
110+ role="dialog"
111+ aria-label={win.title}
112+ tabindex="-1"
113+ onmousedown={() => desktop.focus(id)}
114+ use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
115+ ondragmove={(e) => {
116+ left = e.detail.x;
117+ top = e.detail.y;
118+ }}
119+ use:resizable={{
120+ enabled: resizableWindow && !isMobile && !isMaximized,
121+ minWidth,
122+ minHeight,
123+ maxWidth: typeof window !== 'undefined' ? window.innerWidth - 16 : 1600,
124+ maxHeight: typeof window !== 'undefined' ? window.innerHeight - 40 : 1200
125+ }}
126+ onresizemove={(e) => {
127+ w = Math.max(minWidth, e.detail.width);
128+ h = Math.max(minHeight, e.detail.height);
129+ }}
130+ >
131+ <div class="title-bar" ondblclick={() => showMaximize && toggleMaximize()} role="presentation">
132+ <div class="title-bar-text">
133+ <img src={win.icon} alt="" width="16" height="16" class="title-icon" />
134+ <span class="title-text">{win.title}</span>
135+ </div>
136+ <div class="title-bar-controls">
137+ <button aria-label="Minimize" onclick={minimize}></button>
138+ {#if showMaximize && !isMobile}
139+ <button
140+ aria-label={isMaximized ? 'Restore' : 'Maximize'}
141+ onclick={toggleMaximize}
142+ ></button>
143+ {/if}
144+ <button aria-label="Close" onclick={close}></button>
145+ </div>
146+ </div>
147+ {#if menu}
148+ <div class="xp-menubar">{@render menu()}</div>
149+ {/if}
150+ <div class="window-body xp-body {bodyClass}" class:has-menu={Boolean(menu)}>
151+ {@render children()}
152+ </div>
153+ </div>
154+{/if}
155+
156+<style>
157+ .xp-window {
158+ position: fixed;
159+ box-sizing: border-box;
160+ display: flex;
161+ flex-direction: column;
162+ font-family: Tahoma, 'Pixelated MS Sans Serif', Arial, sans-serif;
163+ box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.35);
164+ }
165+
166+ .xp-window.hidden {
167+ display: none;
168+ }
169+
170+ .xp-window.inactive {
171+ box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
172+ }
173+
174+ /* xp.css already styles active/inactive gradients through :not(:focus-within);
175+ force the inactive look based on our own focus tracking instead. */
176+ .xp-window.inactive .title-bar {
177+ background: linear-gradient(180deg, #7697e7 0%, #7e9ee3 8%, #94afec 40%, #97b4e9 88%, #82a5e4 100%);
178+ }
179+
180+ .title-bar {
181+ cursor: move;
182+ user-select: none;
183+ display: flex;
184+ align-items: center;
185+ flex: 0 0 auto;
186+ }
187+
188+ .title-bar-text {
189+ display: flex;
190+ align-items: center;
191+ gap: 4px;
192+ white-space: nowrap;
193+ overflow: hidden;
194+ text-overflow: ellipsis;
195+ min-width: 0;
196+ }
197+
198+ .title-text {
199+ overflow: hidden;
200+ text-overflow: ellipsis;
201+ }
202+
203+ .title-icon {
204+ flex: 0 0 16px;
205+ }
206+
207+ .xp-menubar {
208+ display: flex;
209+ gap: 2px;
210+ padding: 1px 4px;
211+ background: #ece9d8;
212+ border-bottom: 1px solid #aca899;
213+ font-size: 11px;
214+ flex: 0 0 auto;
215+ }
216+
217+ .xp-menubar :global(button) {
218+ border: 1px solid transparent;
219+ background: transparent;
220+ box-shadow: none;
221+ min-width: 0;
222+ padding: 2px 6px;
223+ font-size: 11px;
224+ border-radius: 0;
225+ }
226+
227+ .xp-menubar :global(button:hover) {
228+ background: #316ac5;
229+ color: white;
230+ }
231+
232+ .xp-body {
233+ flex: 1 1 auto;
234+ min-height: 0;
235+ display: flex;
236+ flex-direction: column;
237+ margin: 0;
238+ padding: 6px;
239+ overflow: hidden;
240+ }
241+
242+ :global(.xp-window .resize-handle) {
243+ position: absolute;
244+ bottom: 0;
245+ right: 0;
246+ width: 15px !important;
247+ height: 15px !important;
248+ cursor: se-resize !important;
249+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Cpath d='M11 11v-2h2v2h-2zm0-4h2v2h-2V7zm-2 2V7h2v2H9zm0 2v-2h2v2H9zm-2 0v-2h2v2H7z' fill='%23555'/%3E%3C/svg%3E");
250+ background-position: bottom right;
251+ background-repeat: no-repeat;
252+ }
253+
254+ @media (max-width: 768px) {
255+ .xp-window.mobile {
256+ left: 0 !important;
257+ top: 0 !important;
258+ width: 100vw !important;
259+ height: calc(100vh - 30px) !important;
260+ border-radius: 0;
261+ }
262+ }
263+</style>
M src/lib/db/convex.server.ts +3 -0
@@ -153,6 +153,9 @@ export interface ConvexChatRoom {
153153 name?: string;
154154 type: string;
155155 createdAt: number;
156+ topic?: string;
157+ memberIds?: string[];
158+ createdBy?: string;
156159 }
157160
158161 export interface ConvexTextPreferences {
@@ -153,6 +153,9 @@ export interface ConvexChatRoom {
153 name?: string;153 name?: string;
154 type: string;154 type: string;
155 createdAt: number;155 createdAt: number;
156+ topic?: string;
157+ memberIds?: string[];
158+ createdBy?: string;
156 }159 }
157 160
158 export interface ConvexTextPreferences {161 export interface ConvexTextPreferences {
M src/lib/states/chat.svelte.ts +14 -0
@@ -12,6 +12,8 @@ class ChatState {
1212 // Current user state (from session)
1313 private _currentUser = $state<SafeUser | null>(null);
1414 private _currentRoomId = $state<string | null>(null);
15+ // SHA-256 hash of the session token: used as `tokenHash` for Convex authQuery/authMutation
16+ private _sessionTokenHash = $state<string | null>(null);
1517
1618 // Connection state for UI feedback
1719 private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected');
@@ -43,6 +45,7 @@ class ChatState {
4345 setCurrentUser(user: User | SafeUser | null) {
4446 if (!user) {
4547 this._currentUser = null;
48+ this._sessionTokenHash = null;
4649 this._connectionStatus = 'disconnected';
4750 this._roomMentionCounts = {};
4851 return;
@@ -53,6 +56,17 @@ class ChatState {
5356 this._connectionStatus = 'connected';
5457 }
5558
59+ /**
60+ * Session token hash for client-side authenticated Convex calls
61+ */
62+ getSessionTokenHash() {
63+ return this._sessionTokenHash;
64+ }
65+
66+ setSessionTokenHash(hash: string | null) {
67+ this._sessionTokenHash = hash;
68+ }
69+
5670 /**
5771 * Get the current room ID
5872 */
@@ -12,6 +12,8 @@ class ChatState {
12 // Current user state (from session)12 // Current user state (from session)
13 private _currentUser = $state<SafeUser | null>(null);13 private _currentUser = $state<SafeUser | null>(null);
14 private _currentRoomId = $state<string | null>(null);14 private _currentRoomId = $state<string | null>(null);
15+ // SHA-256 hash of the session token: used as `tokenHash` for Convex authQuery/authMutation
16+ private _sessionTokenHash = $state<string | null>(null);
15 17
16 // Connection state for UI feedback18 // Connection state for UI feedback
17 private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected');19 private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected');
@@ -43,6 +45,7 @@ class ChatState {
43 setCurrentUser(user: User | SafeUser | null) {45 setCurrentUser(user: User | SafeUser | null) {
44 if (!user) {46 if (!user) {
45 this._currentUser = null;47 this._currentUser = null;
48+ this._sessionTokenHash = null;
46 this._connectionStatus = 'disconnected';49 this._connectionStatus = 'disconnected';
47 this._roomMentionCounts = {};50 this._roomMentionCounts = {};
48 return;51 return;
@@ -53,6 +56,17 @@ class ChatState {
53 this._connectionStatus = 'connected';56 this._connectionStatus = 'connected';
54 }57 }
55 58
59+ /**
60+ * Session token hash for client-side authenticated Convex calls
61+ */
62+ getSessionTokenHash() {
63+ return this._sessionTokenHash;
64+ }
65+
66+ setSessionTokenHash(hash: string | null) {
67+ this._sessionTokenHash = hash;
68+ }
69+
56 /**70 /**
57 * Get the current room ID71 * Get the current room ID
58 */72 */
A src/lib/states/desktop.svelte.ts +232 -0
@@ -0,0 +1,232 @@
1+/**
2+ * Desktop window manager.
3+ *
4+ * Every window on the XP desktop (buddy list, chat rooms, IMs, dialogs) is
5+ * registered here so the taskbar, focus order and z-index are consistent.
6+ */
7+import type { Id } from '../../convex/_generated/dataModel';
8+
9+export type WindowKind =
10+ | 'buddy-list'
11+ | 'chat-room'
12+ | 'im'
13+ | 'login'
14+ | 'profile'
15+ | 'away'
16+ | 'new-room'
17+ | 'preferences'
18+ | 'about';
19+
20+export interface DesktopWindow {
21+ id: string;
22+ kind: WindowKind;
23+ title: string;
24+ icon: string;
25+ z: number;
26+ minimized: boolean;
27+ /** Set when a window wants the taskbar button to blink (new IM). */
28+ attention: boolean;
29+ /** Kind-specific payload */
30+ props: Record<string, unknown>;
31+}
32+
33+export interface ImWindowProps {
34+ roomId: Id<'chatRooms'>;
35+ otherUserId: Id<'users'>;
36+ otherNickname: string;
37+}
38+
39+export interface ChatRoomWindowProps {
40+ roomId: Id<'chatRooms'> | null; // null = default room
41+ roomName: string;
42+}
43+
44+export interface ProfileWindowProps {
45+ userId: Id<'users'>;
46+ nickname: string;
47+}
48+
49+class DesktopState {
50+ windows = $state<DesktopWindow[]>([]);
51+ focusedId = $state<string | null>(null);
52+ private nextZ = 10;
53+
54+ /** Whether the login "sign on" sequence is running (modem animation). */
55+ signingOn = $state(false);
56+
57+ focused = $derived(this.windows.find((w) => w.id === this.focusedId) ?? null);
58+ taskbarWindows = $derived([...this.windows].sort((a, b) => a.id.localeCompare(b.id)));
59+
60+ get(id: string) {
61+ return this.windows.find((w) => w.id === id) ?? null;
62+ }
63+
64+ has(id: string) {
65+ return this.windows.some((w) => w.id === id);
66+ }
67+
68+ /**
69+ * Open (or focus if already open) a window.
70+ * Windows are identified by a stable id so that e.g. an IM with a given
71+ * user is never opened twice.
72+ */
73+ open(win: Omit<DesktopWindow, 'z' | 'minimized' | 'attention'>) {
74+ const existing = this.get(win.id);
75+ if (existing) {
76+ existing.title = win.title;
77+ existing.props = win.props;
78+ this.focus(win.id);
79+ return existing;
80+ }
81+ const created: DesktopWindow = {
82+ ...win,
83+ z: ++this.nextZ,
84+ minimized: false,
85+ attention: false
86+ };
87+ this.windows = [...this.windows, created];
88+ this.focusedId = created.id;
89+ return created;
90+ }
91+
92+ close(id: string) {
93+ this.windows = this.windows.filter((w) => w.id !== id);
94+ if (this.focusedId === id) {
95+ const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
96+ this.focusedId = top?.id ?? null;
97+ }
98+ }
99+
100+ focus(id: string) {
101+ const win = this.get(id);
102+ if (!win) return;
103+ win.minimized = false;
104+ win.attention = false;
105+ win.z = ++this.nextZ;
106+ this.focusedId = id;
107+ }
108+
109+ minimize(id: string) {
110+ const win = this.get(id);
111+ if (!win) return;
112+ win.minimized = true;
113+ if (this.focusedId === id) {
114+ const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
115+ this.focusedId = top?.id ?? null;
116+ }
117+ }
118+
119+ toggleFromTaskbar(id: string) {
120+ const win = this.get(id);
121+ if (!win) return;
122+ if (win.minimized || this.focusedId !== id) {
123+ this.focus(id);
124+ } else {
125+ this.minimize(id);
126+ }
127+ }
128+
129+ requestAttention(id: string) {
130+ const win = this.get(id);
131+ if (win && this.focusedId !== id) win.attention = true;
132+ }
133+
134+ blurAll() {
135+ this.focusedId = null;
136+ }
137+
138+ // ---------- Convenience openers ----------
139+
140+ openBuddyList() {
141+ return this.open({
142+ id: 'buddy-list',
143+ kind: 'buddy-list',
144+ title: 'Liste de contacts',
145+ icon: '/aim/running-man.svg',
146+ props: {}
147+ });
148+ }
149+
150+ openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) {
151+ const props: ChatRoomWindowProps = { roomId, roomName };
152+ return this.open({
153+ id: roomId ? `room-${roomId}` : 'room-default',
154+ kind: 'chat-room',
155+ title: `Salon : ${roomName}`,
156+ icon: '/aim/chat-room.svg',
157+ props: props as unknown as Record<string, unknown>
158+ });
159+ }
160+
161+ openIm(props: ImWindowProps) {
162+ return this.open({
163+ id: `im-${props.roomId}`,
164+ kind: 'im',
165+ title: `${props.otherNickname} - Message instantané`,
166+ icon: '/aim/im.svg',
167+ props: props as unknown as Record<string, unknown>
168+ });
169+ }
170+
171+ openLogin(tab: 'signin' | 'signup' = 'signin') {
172+ return this.open({
173+ id: 'login',
174+ kind: 'login',
175+ title: 'Connexion',
176+ icon: '/aim/running-man.svg',
177+ props: { tab }
178+ });
179+ }
180+
181+ openProfile(props: ProfileWindowProps) {
182+ return this.open({
183+ id: `profile-${props.userId}`,
184+ kind: 'profile',
185+ title: `Infos sur ${props.nickname}`,
186+ icon: '/aim/info.svg',
187+ props: props as unknown as Record<string, unknown>
188+ });
189+ }
190+
191+ openAway() {
192+ return this.open({
193+ id: 'away',
194+ kind: 'away',
195+ title: 'Message d’absence',
196+ icon: '/aim/away.svg',
197+ props: {}
198+ });
199+ }
200+
201+ openNewRoom() {
202+ return this.open({
203+ id: 'new-room',
204+ kind: 'new-room',
205+ title: 'Nouveau salon',
206+ icon: '/aim/chat-room.svg',
207+ props: {}
208+ });
209+ }
210+
211+ openPreferences() {
212+ return this.open({
213+ id: 'preferences',
214+ kind: 'preferences',
215+ title: 'Préférences',
216+ icon: '/aim/setup-icon.png',
217+ props: {}
218+ });
219+ }
220+
221+ openAbout() {
222+ return this.open({
223+ id: 'about',
224+ kind: 'about',
225+ title: 'À propos de PDR AIM',
226+ icon: '/aim/help-icon.png',
227+ props: {}
228+ });
229+ }
230+}
231+
232+export const desktop = new DesktopState();
@@ -0,0 +1,232 @@
1+/**
2+ * Desktop window manager.
3+ *
4+ * Every window on the XP desktop (buddy list, chat rooms, IMs, dialogs) is
5+ * registered here so the taskbar, focus order and z-index are consistent.
6+ */
7+import type { Id } from '../../convex/_generated/dataModel';
8+
9+export type WindowKind =
10+ | 'buddy-list'
11+ | 'chat-room'
12+ | 'im'
13+ | 'login'
14+ | 'profile'
15+ | 'away'
16+ | 'new-room'
17+ | 'preferences'
18+ | 'about';
19+
20+export interface DesktopWindow {
21+ id: string;
22+ kind: WindowKind;
23+ title: string;
24+ icon: string;
25+ z: number;
26+ minimized: boolean;
27+ /** Set when a window wants the taskbar button to blink (new IM). */
28+ attention: boolean;
29+ /** Kind-specific payload */
30+ props: Record<string, unknown>;
31+}
32+
33+export interface ImWindowProps {
34+ roomId: Id<'chatRooms'>;
35+ otherUserId: Id<'users'>;
36+ otherNickname: string;
37+}
38+
39+export interface ChatRoomWindowProps {
40+ roomId: Id<'chatRooms'> | null; // null = default room
41+ roomName: string;
42+}
43+
44+export interface ProfileWindowProps {
45+ userId: Id<'users'>;
46+ nickname: string;
47+}
48+
49+class DesktopState {
50+ windows = $state<DesktopWindow[]>([]);
51+ focusedId = $state<string | null>(null);
52+ private nextZ = 10;
53+
54+ /** Whether the login "sign on" sequence is running (modem animation). */
55+ signingOn = $state(false);
56+
57+ focused = $derived(this.windows.find((w) => w.id === this.focusedId) ?? null);
58+ taskbarWindows = $derived([...this.windows].sort((a, b) => a.id.localeCompare(b.id)));
59+
60+ get(id: string) {
61+ return this.windows.find((w) => w.id === id) ?? null;
62+ }
63+
64+ has(id: string) {
65+ return this.windows.some((w) => w.id === id);
66+ }
67+
68+ /**
69+ * Open (or focus if already open) a window.
70+ * Windows are identified by a stable id so that e.g. an IM with a given
71+ * user is never opened twice.
72+ */
73+ open(win: Omit<DesktopWindow, 'z' | 'minimized' | 'attention'>) {
74+ const existing = this.get(win.id);
75+ if (existing) {
76+ existing.title = win.title;
77+ existing.props = win.props;
78+ this.focus(win.id);
79+ return existing;
80+ }
81+ const created: DesktopWindow = {
82+ ...win,
83+ z: ++this.nextZ,
84+ minimized: false,
85+ attention: false
86+ };
87+ this.windows = [...this.windows, created];
88+ this.focusedId = created.id;
89+ return created;
90+ }
91+
92+ close(id: string) {
93+ this.windows = this.windows.filter((w) => w.id !== id);
94+ if (this.focusedId === id) {
95+ const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
96+ this.focusedId = top?.id ?? null;
97+ }
98+ }
99+
100+ focus(id: string) {
101+ const win = this.get(id);
102+ if (!win) return;
103+ win.minimized = false;
104+ win.attention = false;
105+ win.z = ++this.nextZ;
106+ this.focusedId = id;
107+ }
108+
109+ minimize(id: string) {
110+ const win = this.get(id);
111+ if (!win) return;
112+ win.minimized = true;
113+ if (this.focusedId === id) {
114+ const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
115+ this.focusedId = top?.id ?? null;
116+ }
117+ }
118+
119+ toggleFromTaskbar(id: string) {
120+ const win = this.get(id);
121+ if (!win) return;
122+ if (win.minimized || this.focusedId !== id) {
123+ this.focus(id);
124+ } else {
125+ this.minimize(id);
126+ }
127+ }
128+
129+ requestAttention(id: string) {
130+ const win = this.get(id);
131+ if (win && this.focusedId !== id) win.attention = true;
132+ }
133+
134+ blurAll() {
135+ this.focusedId = null;
136+ }
137+
138+ // ---------- Convenience openers ----------
139+
140+ openBuddyList() {
141+ return this.open({
142+ id: 'buddy-list',
143+ kind: 'buddy-list',
144+ title: 'Liste de contacts',
145+ icon: '/aim/running-man.svg',
146+ props: {}
147+ });
148+ }
149+
150+ openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) {
151+ const props: ChatRoomWindowProps = { roomId, roomName };
152+ return this.open({
153+ id: roomId ? `room-${roomId}` : 'room-default',
154+ kind: 'chat-room',
155+ title: `Salon : ${roomName}`,
156+ icon: '/aim/chat-room.svg',
157+ props: props as unknown as Record<string, unknown>
158+ });
159+ }
160+
161+ openIm(props: ImWindowProps) {
162+ return this.open({
163+ id: `im-${props.roomId}`,
164+ kind: 'im',
165+ title: `${props.otherNickname} - Message instantané`,
166+ icon: '/aim/im.svg',
167+ props: props as unknown as Record<string, unknown>
168+ });
169+ }
170+
171+ openLogin(tab: 'signin' | 'signup' = 'signin') {
172+ return this.open({
173+ id: 'login',
174+ kind: 'login',
175+ title: 'Connexion',
176+ icon: '/aim/running-man.svg',
177+ props: { tab }
178+ });
179+ }
180+
181+ openProfile(props: ProfileWindowProps) {
182+ return this.open({
183+ id: `profile-${props.userId}`,
184+ kind: 'profile',
185+ title: `Infos sur ${props.nickname}`,
186+ icon: '/aim/info.svg',
187+ props: props as unknown as Record<string, unknown>
188+ });
189+ }
190+
191+ openAway() {
192+ return this.open({
193+ id: 'away',
194+ kind: 'away',
195+ title: 'Message d’absence',
196+ icon: '/aim/away.svg',
197+ props: {}
198+ });
199+ }
200+
201+ openNewRoom() {
202+ return this.open({
203+ id: 'new-room',
204+ kind: 'new-room',
205+ title: 'Nouveau salon',
206+ icon: '/aim/chat-room.svg',
207+ props: {}
208+ });
209+ }
210+
211+ openPreferences() {
212+ return this.open({
213+ id: 'preferences',
214+ kind: 'preferences',
215+ title: 'Préférences',
216+ icon: '/aim/setup-icon.png',
217+ props: {}
218+ });
219+ }
220+
221+ openAbout() {
222+ return this.open({
223+ id: 'about',
224+ kind: 'about',
225+ title: 'À propos de PDR AIM',
226+ icon: '/aim/help-icon.png',
227+ props: {}
228+ });
229+ }
230+}
231+
232+export const desktop = new DesktopState();
A src/lib/types/aim.ts +45 -0
@@ -0,0 +1,45 @@
1+import type { Id } from '../../convex/_generated/dataModel';
2+
3+export type AimStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline';
4+
5+/** Public user shape returned by Convex queries (getUsersPublic, getMyBuddies…) */
6+export interface AimUser {
7+ id: Id<'users'>;
8+ nickname: string;
9+ status: AimStatus;
10+ avatarUrl?: string | null;
11+ lastSeen?: number | null;
12+ awayMessage?: string;
13+ buddyIcon?: string;
14+ createdAt?: number;
15+}
16+
17+export interface Buddy extends AimUser {
18+ group: string;
19+ addedAt: number;
20+}
21+
22+export interface DirectRoomSummary {
23+ id: Id<'chatRooms'>;
24+ other: AimUser | null;
25+ lastMessage: { content: string; timestamp: number; senderId: Id<'users'> } | null;
26+ unreadCount: number;
27+}
28+
29+export interface GroupRoomSummary {
30+ id: Id<'chatRooms'>;
31+ name: string;
32+ topic?: string;
33+ createdAt: number;
34+ isDefault: boolean;
35+}
36+
37+export const DEFAULT_BUDDY_GROUPS = ['Buddies', 'Family', 'Co-Workers'] as const;
38+
39+export const STATUS_LABELS_FR: Record<AimStatus, string> = {
40+ online: 'En ligne',
41+ away: 'Absent',
42+ busy: 'Occupé',
43+ idle: 'Inactif',
44+ offline: 'Hors ligne'
45+};
@@ -0,0 +1,45 @@
1+import type { Id } from '../../convex/_generated/dataModel';
2+
3+export type AimStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline';
4+
5+/** Public user shape returned by Convex queries (getUsersPublic, getMyBuddies…) */
6+export interface AimUser {
7+ id: Id<'users'>;
8+ nickname: string;
9+ status: AimStatus;
10+ avatarUrl?: string | null;
11+ lastSeen?: number | null;
12+ awayMessage?: string;
13+ buddyIcon?: string;
14+ createdAt?: number;
15+}
16+
17+export interface Buddy extends AimUser {
18+ group: string;
19+ addedAt: number;
20+}
21+
22+export interface DirectRoomSummary {
23+ id: Id<'chatRooms'>;
24+ other: AimUser | null;
25+ lastMessage: { content: string; timestamp: number; senderId: Id<'users'> } | null;
26+ unreadCount: number;
27+}
28+
29+export interface GroupRoomSummary {
30+ id: Id<'chatRooms'>;
31+ name: string;
32+ topic?: string;
33+ createdAt: number;
34+ isDefault: boolean;
35+}
36+
37+export const DEFAULT_BUDDY_GROUPS = ['Buddies', 'Family', 'Co-Workers'] as const;
38+
39+export const STATUS_LABELS_FR: Record<AimStatus, string> = {
40+ online: 'En ligne',
41+ away: 'Absent',
42+ busy: 'Occupé',
43+ idle: 'Inactif',
44+ offline: 'Hors ligne'
45+};
M src/routes/+layout.svelte +3 -0
@@ -227,6 +227,9 @@
227227 if (data.session?.id) {
228228 console.debug('Updating global chatState with data.user:', data.user);
229229 chatState.setCurrentUser(data.user);
230+ chatState.setSessionTokenHash(data.session.id);
231+ } else {
232+ chatState.setCurrentUser(null);
230233 }
231234 });
232235 </script>
@@ -227,6 +227,9 @@
227 if (data.session?.id) {227 if (data.session?.id) {
228 console.debug('Updating global chatState with data.user:', data.user);228 console.debug('Updating global chatState with data.user:', data.user);
229 chatState.setCurrentUser(data.user);229 chatState.setCurrentUser(data.user);
230+ chatState.setSessionTokenHash(data.session.id);
231+ } else {
232+ chatState.setCurrentUser(null);
230 }233 }
231 });234 });
232 </script>235 </script>
M src/routes/api/chat/messages/+server.ts +14 -2
@@ -1,8 +1,8 @@
1-import { messages } from '$lib/db/convex.server';
1+import { messages, chatRooms } from '$lib/db/convex.server';
22 import { getDefaultChatRoomId } from '$lib/utils/chat.server';
33 import type { Message } from '$lib/types/chat';
44 import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads';
5-import { error } from '@sveltejs/kit';
5+import { error, isHttpError } from '@sveltejs/kit';
66 import { createLogger } from '$lib/utils/logger.server';
77 import { sanitizeStyleData } from '$lib/validation/text-formatting';
88 import { sendMessageSchema } from '$lib/validation/message';
@@ -60,6 +60,17 @@ export const GET: RequestHandler = async ({ url, locals }) => {
6060 }
6161
6262 try {
63+ // Direct (IM) rooms are private to their two members
64+ const room = await chatRooms.getById(roomId);
65+ if (!room) {
66+ throw error(404, 'Chat room not found');
67+ }
68+ if (room.type === 'direct') {
69+ if (!locals.user || !(room.memberIds ?? []).includes(locals.user.id)) {
70+ throw error(403, 'Not a member of this conversation');
71+ }
72+ }
73+
6374 log.debug('Fetching messages from Convex', {
6475 beforeTimestamp,
6576 roomId,
@@ -109,6 +120,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
109120 headers: { 'Content-Type': 'application/json' }
110121 });
111122 } catch (err) {
123+ if (isHttpError(err)) throw err;
112124 log.error('Error fetching messages:', { error: err });
113125 throw error(500, 'Failed to fetch messages');
114126 }
@@ -1,8 +1,8 @@
1-import { messages } from '$lib/db/convex.server';1+import { messages, chatRooms } from '$lib/db/convex.server';
2 import { getDefaultChatRoomId } from '$lib/utils/chat.server';2 import { getDefaultChatRoomId } from '$lib/utils/chat.server';
3 import type { Message } from '$lib/types/chat';3 import type { Message } from '$lib/types/chat';
4 import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads';4 import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads';
5-import { error } from '@sveltejs/kit';5+import { error, isHttpError } from '@sveltejs/kit';
6 import { createLogger } from '$lib/utils/logger.server';6 import { createLogger } from '$lib/utils/logger.server';
7 import { sanitizeStyleData } from '$lib/validation/text-formatting';7 import { sanitizeStyleData } from '$lib/validation/text-formatting';
8 import { sendMessageSchema } from '$lib/validation/message';8 import { sendMessageSchema } from '$lib/validation/message';
@@ -60,6 +60,17 @@ export const GET: RequestHandler = async ({ url, locals }) => {
60 }60 }
61 61
62 try {62 try {
63+ // Direct (IM) rooms are private to their two members
64+ const room = await chatRooms.getById(roomId);
65+ if (!room) {
66+ throw error(404, 'Chat room not found');
67+ }
68+ if (room.type === 'direct') {
69+ if (!locals.user || !(room.memberIds ?? []).includes(locals.user.id)) {
70+ throw error(403, 'Not a member of this conversation');
71+ }
72+ }
73+
63 log.debug('Fetching messages from Convex', {74 log.debug('Fetching messages from Convex', {
64 beforeTimestamp,75 beforeTimestamp,
65 roomId,76 roomId,
@@ -109,6 +120,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
109 headers: { 'Content-Type': 'application/json' }120 headers: { 'Content-Type': 'application/json' }
110 });121 });
111 } catch (err) {122 } catch (err) {
123+ if (isHttpError(err)) throw err;
112 log.error('Error fetching messages:', { error: err });124 log.error('Error fetching messages:', { error: err });
113 throw error(500, 'Failed to fetch messages');125 throw error(500, 'Failed to fetch messages');
114 }126 }
A static/aim/away.svg +4 -0
@@ -0,0 +1,4 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2+ <rect x="1" y="3" width="14" height="10" fill="#fff8c4" stroke="#b58b00"/>
3+ <path d="M3 6h10M3 8.5h7M3 11h5" stroke="#b58b00" stroke-width="1"/>
4+</svg>
@@ -0,0 +1,4 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2+ <rect x="1" y="3" width="14" height="10" fill="#fff8c4" stroke="#b58b00"/>
3+ <path d="M3 6h10M3 8.5h7M3 11h5" stroke="#b58b00" stroke-width="1"/>
4+</svg>
A static/aim/chat-room.svg +6 -0
@@ -0,0 +1,6 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2+ <circle cx="5" cy="5" r="2.5" fill="#f6c700" stroke="#8a6d00"/>
3+ <circle cx="11" cy="5" r="2.5" fill="#f6c700" stroke="#8a6d00"/>
4+ <path d="M1 14c0-3 2-5 4-5s4 2 4 5z" fill="#3b7ddd"/>
5+ <path d="M7 14c0-3 2-5 4-5s4 2 4 5z" fill="#e0512f"/>
6+</svg>
@@ -0,0 +1,6 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2+ <circle cx="5" cy="5" r="2.5" fill="#f6c700" stroke="#8a6d00"/>
3+ <circle cx="11" cy="5" r="2.5" fill="#f6c700" stroke="#8a6d00"/>
4+ <path d="M1 14c0-3 2-5 4-5s4 2 4 5z" fill="#3b7ddd"/>
5+ <path d="M7 14c0-3 2-5 4-5s4 2 4 5z" fill="#e0512f"/>
6+</svg>
A static/aim/door-closed.svg +4 -0
@@ -0,0 +1,4 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2+ <rect x="3" y="1" width="10" height="14" fill="#8b4513" stroke="#5c3310"/>
3+ <circle cx="11" cy="8" r="0.8" fill="#ffd700"/>
4+</svg>
@@ -0,0 +1,4 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2+ <rect x="3" y="1" width="10" height="14" fill="#8b4513" stroke="#5c3310"/>
3+ <circle cx="11" cy="8" r="0.8" fill="#ffd700"/>
4+</svg>
A static/aim/door-open.svg +5 -0
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2+ <rect x="2" y="1" width="8" height="14" fill="#b5651d" stroke="#5c3310"/>
3+ <path d="M10 1l4 2v10l-4 2z" fill="#8b4513" stroke="#5c3310"/>
4+ <circle cx="8.5" cy="8" r="0.8" fill="#ffd700"/>
5+</svg>
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2+ <rect x="2" y="1" width="8" height="14" fill="#b5651d" stroke="#5c3310"/>
3+ <path d="M10 1l4 2v10l-4 2z" fill="#8b4513" stroke="#5c3310"/>
4+ <circle cx="8.5" cy="8" r="0.8" fill="#ffd700"/>
5+</svg>
A static/aim/im.svg +5 -0
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2+ <path d="M1 2h14v9H6l-3 3v-3H1z" fill="#fff" stroke="#1d4fa8"/>
3+ <rect x="3" y="4" width="10" height="1.5" fill="#1d4fa8"/>
4+ <rect x="3" y="7" width="7" height="1.5" fill="#1d4fa8"/>
5+</svg>
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2+ <path d="M1 2h14v9H6l-3 3v-3H1z" fill="#fff" stroke="#1d4fa8"/>
3+ <rect x="3" y="4" width="10" height="1.5" fill="#1d4fa8"/>
4+ <rect x="3" y="7" width="7" height="1.5" fill="#1d4fa8"/>
5+</svg>
A static/aim/info.svg +5 -0
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2+ <circle cx="8" cy="8" r="7" fill="#3b7ddd" stroke="#1d4fa8"/>
3+ <rect x="7" y="7" width="2" height="5" fill="#fff"/>
4+ <circle cx="8" cy="4.5" r="1.2" fill="#fff"/>
5+</svg>
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2+ <circle cx="8" cy="8" r="7" fill="#3b7ddd" stroke="#1d4fa8"/>
3+ <rect x="7" y="7" width="2" height="5" fill="#fff"/>
4+ <circle cx="8" cy="4.5" r="1.2" fill="#fff"/>
5+</svg>
A static/aim/running-man.svg +7 -0
@@ -0,0 +1,7 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
2+ <circle cx="16" cy="16" r="15" fill="#f6c700" stroke="#c99a00" stroke-width="1"/>
3+ <g fill="#1b1b1b">
4+ <circle cx="19" cy="8" r="3"/>
5+ <path d="M14 12 l6 1 l3 5 l-2 1 l-2.5 -3 l-1 4 l4 4 l-1.5 5 l-2.5 -1 l1 -3.5 l-4 -3 l-3 5 l-2.5 -1.5 l3.5 -6 l1.5 -4 l-4 1 l-1 -2.5z"/>
6+ </g>
7+</svg>
@@ -0,0 +1,7 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
2+ <circle cx="16" cy="16" r="15" fill="#f6c700" stroke="#c99a00" stroke-width="1"/>
3+ <g fill="#1b1b1b">
4+ <circle cx="19" cy="8" r="3"/>
5+ <path d="M14 12 l6 1 l3 5 l-2 1 l-2.5 -3 l-1 4 l4 4 l-1.5 5 l-2.5 -1 l1 -3.5 l-4 -3 l-3 5 l-2.5 -1.5 l3.5 -6 l1.5 -4 l-4 1 l-1 -2.5z"/>
6+ </g>
7+</svg>
A static/aim/start.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M1 3l6-1v6H1zM8 2l7-1v7H8zM1 9h6v6l-6-1zM8 9h7v7l-7-1z" fill="#fff"/><path d="M1 3l6-1v6H1z" fill="#f35325"/><path d="M8 2l7-1v7H8z" fill="#81bc06"/><path d="M1 9h6v6l-6-1z" fill="#05a6f0"/><path d="M8 9h7v7l-7-1z" fill="#ffba08"/></svg>
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M1 3l6-1v6H1zM8 2l7-1v7H8zM1 9h6v6l-6-1zM8 9h7v7l-7-1z" fill="#fff"/><path d="M1 3l6-1v6H1z" fill="#f35325"/><path d="M8 2l7-1v7H8z" fill="#81bc06"/><path d="M1 9h6v6l-6-1z" fill="#05a6f0"/><path d="M8 9h7v7l-7-1z" fill="#ffba08"/></svg>
A static/aim/status-away.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><rect x="1" y="2" width="10" height="8" fill="#fff8c4" stroke="#b58b00"/><path d="M3 5h6M3 7h4" stroke="#b58b00"/></svg>
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><rect x="1" y="2" width="10" height="8" fill="#fff8c4" stroke="#b58b00"/><path d="M3 5h6M3 7h4" stroke="#b58b00"/></svg>
A static/aim/status-busy.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="#e0512f" stroke="#8a2a10"/><rect x="3" y="5.2" width="6" height="1.6" fill="#fff"/></svg>
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="#e0512f" stroke="#8a2a10"/><rect x="3" y="5.2" width="6" height="1.6" fill="#fff"/></svg>
A static/aim/status-idle.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="#d9d9d9" stroke="#8a8a8a"/><path d="M4.5 3.5l3 1-1.5 4-1.5-1z" fill="#6b6b6b"/></svg>
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="#d9d9d9" stroke="#8a8a8a"/><path d="M4.5 3.5l3 1-1.5 4-1.5-1z" fill="#6b6b6b"/></svg>
A static/aim/status-offline.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="#8a8a8a" stroke-dasharray="2 1.5"/></svg>
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="#8a8a8a" stroke-dasharray="2 1.5"/></svg>
A static/aim/status-online.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="#f6c700" stroke="#8a6d00"/><path d="M4.5 3.5l3 1-1.5 4-1.5-1z" fill="#1b1b1b"/></svg>
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="#f6c700" stroke="#8a6d00"/><path d="M4.5 3.5l3 1-1.5 4-1.5-1z" fill="#1b1b1b"/></svg>

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