detached 3611559

Merge pull request 'feat(aim): AIM desktop experience' (#1) from feat/aim-desktop-experience into main

commit
361155976205cb391a1204e3bc14394016c99537
author
bnema · · 2026-10-10 08:54 UTC
merge of
66b04ed + 1e972f6 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
84 files · +5450 -1465
M .gitignore +7 -1
@@ -30,4 +30,10 @@ docs/
3030 migration/
3131
3232 # Plans
33-docs/plans/⏎̸
33+docs/plans/
34+# Local audio samples (copyrighted, never committed)
35+static/sounds/*
36+!static/sounds/README.md
37+# AIM smiley GIFs (AOL artwork, never committed)
38+static/smileys/*
39+!static/smileys/README.md
M _headers +7 -0
@@ -15,4 +15,11 @@
1515 Cache-Control: public, max-age=2592000
1616
1717 /desktop/*
18+ Cache-Control: public, max-age=2592000
19+
20+# Optional AIM samples and smileys (only present when dropped in locally)
21+/sounds/*
22+ Cache-Control: public, max-age=2592000
23+
24+/smileys/*
1825 Cache-Control: public, max-age=2592000⏎̸
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;
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 *
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;
A src/convex/aim.ts +391 -0
@@ -0,0 +1,391 @@
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+// Unread badges stop counting here (shown as "99+")
22+export const MAX_UNREAD_COUNT = 100;
23+
24+export type EffectiveStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline';
25+
26+export function computeEffectiveStatus(
27+ status: string,
28+ lastSeen: number | undefined
29+): EffectiveStatus {
30+ if (status === 'offline') return 'offline';
31+ if ((lastSeen ?? 0) < Date.now() - ONLINE_TIMEOUT_MS) return 'offline';
32+ return status as EffectiveStatus;
33+}
34+
35+export function toPublicUser(user: Doc<'users'>) {
36+ const status = computeEffectiveStatus(user.status, user.lastSeen);
37+ return {
38+ id: user._id,
39+ nickname: user.nickname,
40+ status,
41+ avatarUrl: user.avatarUrl,
42+ lastSeen: user.lastSeen,
43+ awayMessage: status === 'away' ? user.awayMessage : undefined,
44+ createdAt: user.createdAt
45+ };
46+}
47+
48+export function directKeyFor(a: Id<'users'>, b: Id<'users'>) {
49+ return [a, b].sort().join(':');
50+}
51+
52+export async function assertRoomAccess(ctx: QueryCtx, room: Doc<'chatRooms'>, userId: Id<'users'>) {
53+ if (room.type === 'direct' && !(room.memberIds ?? []).includes(userId)) {
54+ throw new Error('Not a member of this conversation');
55+ }
56+}
57+
58+// ============ PUBLIC ============
59+
60+export const getUserProfile = query({
61+ args: { userId: v.id('users') },
62+ handler: async (ctx, args) => {
63+ const user = await ctx.db.get(args.userId);
64+ if (!user) return null;
65+ return { ...toPublicUser(user), profile: user.profile };
66+ }
67+});
68+
69+export const getGroupRooms = query({
70+ args: {},
71+ handler: async (ctx) => {
72+ const rooms = await ctx.db.query('chatRooms').collect();
73+ return rooms
74+ .filter((r) => r.type === 'group')
75+ .sort((a, b) => a.createdAt - b.createdAt)
76+ .map((r) => ({
77+ id: r._id,
78+ name: r.name ?? 'Sans nom',
79+ topic: r.topic,
80+ createdAt: r.createdAt,
81+ isDefault: r.name === 'General'
82+ }));
83+ }
84+});
85+
86+// ============ AUTHENTICATED QUERIES ============
87+
88+export const getMyBuddies = authQuery({
89+ args: {},
90+ handler: async (ctx) => {
91+ const rows = await ctx.db
92+ .query('buddies')
93+ .withIndex('by_user', (q) => q.eq('userId', ctx.user._id))
94+ .collect();
95+ const buddies = await Promise.all(
96+ rows.map(async (row) => {
97+ const user = await ctx.db.get(row.buddyId);
98+ if (!user) return null;
99+ return { ...toPublicUser(user), group: row.group, addedAt: row.createdAt };
100+ })
101+ );
102+ return buddies.filter((b) => b !== null);
103+ }
104+});
105+
106+export const getMyDirectRooms = authQuery({
107+ args: {},
108+ handler: async (ctx) => {
109+ const me = ctx.user._id;
110+ const rooms = (await ctx.db.query('chatRooms').collect()).filter(
111+ (r) => r.type === 'direct' && (r.memberIds ?? []).includes(me)
112+ );
113+ const reads = await ctx.db
114+ .query('roomReads')
115+ .withIndex('by_user', (q) => q.eq('userId', me))
116+ .collect();
117+ const readMap = new Map(reads.map((r) => [r.roomId, r.lastReadTimestamp]));
118+
119+ const result = await Promise.all(
120+ rooms.map(async (room) => {
121+ const otherId = (room.memberIds ?? []).find((id) => id !== me);
122+ const other = otherId ? await ctx.db.get(otherId) : null;
123+ const lastRead = readMap.get(room._id) ?? 0;
124+ const lastMessage = await ctx.db
125+ .query('messages')
126+ .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', room._id))
127+ .order('desc')
128+ .first();
129+ // Incoming messages after the read cursor, capped for the "99+" badge. Every index
130+ // ends with _creationTime, so the range scan stops at the cursor.
131+ const unreadCount = (
132+ await ctx.db
133+ .query('messages')
134+ .withIndex('by_chatRoom', (q) =>
135+ q.eq('chatRoomId', room._id).gt('_creationTime', lastRead)
136+ )
137+ .filter((q) => q.neq(q.field('senderId'), me))
138+ .take(MAX_UNREAD_COUNT)
139+ ).length;
140+ return {
141+ id: room._id,
142+ other: other ? toPublicUser(other) : null,
143+ lastMessage: lastMessage
144+ ? {
145+ content: lastMessage.content,
146+ timestamp: lastMessage.timestamp,
147+ senderId: lastMessage.senderId
148+ }
149+ : null,
150+ unreadCount
151+ };
152+ })
153+ );
154+ return result.sort((a, b) => (b.lastMessage?.timestamp ?? 0) - (a.lastMessage?.timestamp ?? 0));
155+ }
156+});
157+
158+export const getRoomMessages = authQuery({
159+ args: { roomId: v.id('chatRooms'), limit: v.optional(v.number()) },
160+ handler: async (ctx, args) => {
161+ const room = await ctx.db.get(args.roomId);
162+ if (!room) return [];
163+ await assertRoomAccess(ctx, room, ctx.user._id);
164+ const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 100)));
165+ const messages = await ctx.db
166+ .query('messages')
167+ .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
168+ .order('desc')
169+ .take(limit);
170+ const enriched = await Promise.all(
171+ messages.map(async (msg) => {
172+ const sender = await ctx.db.get(msg.senderId);
173+ return {
174+ id: msg._id,
175+ chatRoomId: msg.chatRoomId,
176+ senderId: msg.senderId,
177+ content: msg.content,
178+ type: msg.type,
179+ timestamp: msg.timestamp,
180+ styleData: msg.styleData,
181+ hasFormatting: msg.hasFormatting,
182+ sender: sender
183+ ? {
184+ id: sender._id,
185+ nickname: sender.nickname,
186+ status: computeEffectiveStatus(sender.status, sender.lastSeen),
187+ avatarUrl: sender.avatarUrl
188+ }
189+ : null
190+ };
191+ })
192+ );
193+ return enriched.reverse();
194+ }
195+});
196+
197+export const getRoomTyping = authQuery({
198+ args: { roomId: v.id('chatRooms') },
199+ handler: async (ctx, args) => {
200+ const room = await ctx.db.get(args.roomId);
201+ if (!room) return [];
202+ await assertRoomAccess(ctx, room, ctx.user._id);
203+ const since = Date.now() - TYPING_TTL_MS;
204+ const rows = await ctx.db
205+ .query('typing')
206+ .withIndex('by_room', (q) => q.eq('roomId', args.roomId))
207+ .collect();
208+ const active = rows.filter((r) => r.updatedAt > since && r.userId !== ctx.user._id);
209+ const users = await Promise.all(active.map((r) => ctx.db.get(r.userId)));
210+ return users.filter((u) => u !== null).map((u) => ({ id: u._id, nickname: u.nickname }));
211+ }
212+});
213+
214+export const getMe = authQuery({
215+ args: {},
216+ handler: async (ctx) => {
217+ const u = ctx.user;
218+ return {
219+ id: u._id,
220+ nickname: u.nickname,
221+ status: u.status,
222+ awayMessage: u.awayMessage,
223+ profile: u.profile,
224+ createdAt: u.createdAt
225+ };
226+ }
227+});
228+
229+// ============ AUTHENTICATED MUTATIONS ============
230+
231+export const setAwayMessage = authMutation({
232+ args: { awayMessage: v.optional(v.string()) },
233+ handler: async (ctx, args) => {
234+ const text = args.awayMessage?.trim().slice(0, MAX_AWAY_LENGTH);
235+ if (text) {
236+ await ctx.db.patch(ctx.user._id, { awayMessage: text, status: 'away', lastSeen: Date.now() });
237+ } else {
238+ await ctx.db.patch(ctx.user._id, {
239+ awayMessage: undefined,
240+ status: 'online',
241+ lastSeen: Date.now()
242+ });
243+ }
244+ return { success: true };
245+ }
246+});
247+
248+export const updateProfile = authMutation({
249+ args: { profile: v.optional(v.string()) },
250+ handler: async (ctx, args) => {
251+ const patch: Partial<Doc<'users'>> = {};
252+ if (args.profile !== undefined)
253+ patch.profile = args.profile.trim().slice(0, MAX_PROFILE_LENGTH);
254+ await ctx.db.patch(ctx.user._id, patch);
255+ return { success: true };
256+ }
257+});
258+
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 room = await ctx.db.get(args.roomId);
344+ if (!room) throw new Error('Chat room not found');
345+ await assertRoomAccess(ctx, room, ctx.user._id);
346+ const now = Date.now();
347+ const existing = await ctx.db
348+ .query('roomReads')
349+ .withIndex('by_user_room', (q) => q.eq('userId', ctx.user._id).eq('roomId', args.roomId))
350+ .first();
351+ if (existing) {
352+ if (existing.lastReadTimestamp < now) {
353+ await ctx.db.patch(existing._id, { lastReadTimestamp: now });
354+ }
355+ } else {
356+ await ctx.db.insert('roomReads', {
357+ userId: ctx.user._id,
358+ roomId: args.roomId,
359+ lastReadTimestamp: now
360+ });
361+ }
362+ return { success: true };
363+ }
364+});
365+
366+export const setTyping = authMutation({
367+ args: { roomId: v.id('chatRooms'), typing: v.boolean() },
368+ handler: async (ctx, args) => {
369+ const room = await ctx.db.get(args.roomId);
370+ if (!room) throw new Error('Chat room not found');
371+ await assertRoomAccess(ctx, room, ctx.user._id);
372+ const existing = await ctx.db
373+ .query('typing')
374+ .withIndex('by_user_room', (q) => q.eq('userId', ctx.user._id).eq('roomId', args.roomId))
375+ .first();
376+ if (!args.typing) {
377+ if (existing) await ctx.db.delete(existing._id);
378+ return { success: true };
379+ }
380+ if (existing) {
381+ await ctx.db.patch(existing._id, { updatedAt: Date.now() });
382+ } else {
383+ await ctx.db.insert('typing', {
384+ userId: ctx.user._id,
385+ roomId: args.roomId,
386+ updatedAt: Date.now()
387+ });
388+ }
389+ return { success: true };
390+ }
391+});
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,
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,
M src/convex/queries.ts +43 -47
@@ -1,6 +1,8 @@
11 import { query } from './_generated/server';
2+import type { QueryCtx } from './_generated/server';
23 import { authQuery } from './auth';
34 import { v } from 'convex/values';
5+import { toPublicUser, assertRoomAccess } from './aim';
46
57 // Timeout threshold for marking users as offline (2 minutes)
68 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;
@@ -25,9 +27,22 @@ function computeEffectiveStatus(
2527
2628 // ============ PUBLIC QUERIES (no auth required) ============
2729
28-// Public query: Get messages for a chat room (real-time subscription)
29-// Max 100 messages for public/unauthenticated users
30-const PUBLIC_MESSAGE_LIMIT = 100;
30+// Visitors (not signed in) only see the latest messages of the default room.
31+// Keep in sync with PUBLIC_PREVIEW_MESSAGES in src/lib/validation/message.ts.
32+const PUBLIC_MESSAGE_LIMIT = 50;
33+const MAX_AUTH_MESSAGE_LIMIT = 200;
34+
35+// Default room: "General", or the first group room when it does not exist
36+async function findDefaultRoom(ctx: QueryCtx) {
37+ const rooms = await ctx.db.query('chatRooms').collect();
38+ return (
39+ rooms.find((r) => r.name === 'General' && r.type === 'group') ??
40+ rooms.find((r) => r.type === 'group') ??
41+ null
42+ );
43+}
44+
45+// Public query: latest messages of the default room (real-time subscription)
3146
3247 export const getMessagesPublic = query({
3348 args: {
@@ -35,6 +50,10 @@ export const getMessagesPublic = query({
3550 limit: v.optional(v.number())
3651 },
3752 handler: async (ctx, args) => {
53+ // Only the default room is readable without an account
54+ const defaultRoom = await findDefaultRoom(ctx);
55+ if (!defaultRoom || defaultRoom._id !== args.roomId) return [];
56+
3857 // Enforce max limit and clamp to a safe positive integer
3958 const rawLimit = args.limit ?? PUBLIC_MESSAGE_LIMIT;
4059 const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit)));
@@ -75,16 +94,20 @@ export const getMessagesPublic = query({
7594 }
7695 });
7796
78-// Public query: Get paginated messages for a chat room (older history)
79-export const getMessagesPublicPage = query({
97+// Authenticated query: older history of a room (pagination is members-only)
98+export const getMessagesPage = authQuery({
8099 args: {
81100 roomId: v.id('chatRooms'),
82101 limit: v.optional(v.number()),
83102 beforeTimestamp: v.optional(v.number())
84103 },
85104 handler: async (ctx, args) => {
105+ const room = await ctx.db.get(args.roomId);
106+ if (!room) return { messages: [], hasMore: false };
107+ await assertRoomAccess(ctx, room, ctx.user._id);
108+
86109 const rawLimit = args.limit ?? 50;
87- const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit)));
110+ const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
88111
89112 let query = ctx.db
90113 .query('messages')
@@ -122,13 +145,7 @@ export const getUsersPublic = query({
122145 const users = await ctx.db.query('users').collect();
123146
124147 // 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- }));
148+ return users.map(toPublicUser);
132149 }
133150 });
134151
@@ -136,30 +153,10 @@ export const getUsersPublic = query({
136153 export const getDefaultRoomPublic = query({
137154 args: {},
138155 handler: async (ctx) => {
139- const rooms = await ctx.db.query('chatRooms').collect();
140- const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group');
141-
142- if (defaultRoom) {
143- return {
144- id: defaultRoom._id,
145- name: defaultRoom.name,
146- type: defaultRoom.type,
147- createdAt: defaultRoom.createdAt
148- };
149- }
150-
151- // Return first group room if General doesn't exist
152- const firstGroup = rooms.find((r) => r.type === 'group');
153- if (firstGroup) {
154- return {
155- id: firstGroup._id,
156- name: firstGroup.name,
157- type: firstGroup.type,
158- createdAt: firstGroup.createdAt
159- };
160- }
161-
162- return null;
156+ const room = await findDefaultRoom(ctx);
157+ return room
158+ ? { id: room._id, name: room.name, type: room.type, createdAt: room.createdAt }
159+ : null;
163160 }
164161 });
165162
@@ -172,7 +169,10 @@ export const getMessages = authQuery({
172169 limit: v.optional(v.number())
173170 },
174171 handler: async (ctx, args) => {
175- const MAX_AUTH_MESSAGE_LIMIT = 200;
172+ const room = await ctx.db.get(args.roomId);
173+ if (!room) return [];
174+ await assertRoomAccess(ctx, room, ctx.user._id);
175+
176176 const rawLimit = args.limit ?? 100;
177177 const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
178178 const query = ctx.db
@@ -219,21 +219,17 @@ export const getUsers = authQuery({
219219 const users = await ctx.db.query('users').collect();
220220
221221 // 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- }));
222+ return users.map(toPublicUser);
229223 }
230224 });
231225
232-// Authenticated query: Get chat rooms
226+// Authenticated query: Get chat rooms (direct rooms only when the user is a member)
233227 export const getChatRooms = authQuery({
234228 args: {},
235229 handler: async (ctx) => {
236- const rooms = await ctx.db.query('chatRooms').collect();
230+ const rooms = (await ctx.db.query('chatRooms').collect()).filter(
231+ (room) => room.type !== 'direct' || (room.memberIds ?? []).includes(ctx.user._id)
232+ );
237233 return rooms.map((room) => ({
238234 id: room._id,
239235 name: room.name,
M src/convex/schema.ts +42 -5
@@ -5,11 +5,14 @@ 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
1316 })
1417 .index('by_nickname', ['nickname'])
1518 .index('by_status', ['status']),
@@ -26,14 +29,20 @@ export default defineSchema({
2629 chatRooms: defineTable({
2730 name: v.optional(v.string()),
2831 type: v.string(), // 'direct' | 'group'
29- createdAt: v.number()
30- }),
32+ createdAt: v.number(),
33+ createdBy: v.optional(v.id('users')),
34+ topic: v.optional(v.string()),
35+ // Direct rooms: exactly two members. Group rooms: undefined (open to all).
36+ memberIds: v.optional(v.array(v.id('users'))),
37+ // Stable key for direct rooms: sorted user ids joined by ':'
38+ directKey: v.optional(v.string())
39+ }).index('by_directKey', ['directKey']),
3140
3241 messages: defineTable({
3342 chatRoomId: v.id('chatRooms'),
3443 senderId: v.id('users'),
3544 content: v.string(),
36- type: v.string(), // 'chat' | 'emote' | 'system'
45+ type: v.string(), // 'chat' | 'emote' | 'system' | 'auto-response'
3746 timestamp: v.number(),
3847 styleData: v.optional(v.string()),
3948 hasFormatting: v.optional(v.boolean())
@@ -41,6 +50,34 @@ export default defineSchema({
4150 .index('by_chatRoom', ['chatRoomId'])
4251 .index('by_timestamp', ['timestamp']),
4352
53+ // AIM buddy list: one row per (owner, buddy)
54+ buddies: defineTable({
55+ userId: v.id('users'),
56+ buddyId: v.id('users'),
57+ group: v.string(), // 'Buddies' | 'Family' | 'Co-Workers' | custom
58+ createdAt: v.number()
59+ })
60+ .index('by_user', ['userId'])
61+ .index('by_user_buddy', ['userId', 'buddyId']),
62+
63+ // Per-user read cursor per room (unread badges)
64+ roomReads: defineTable({
65+ userId: v.id('users'),
66+ roomId: v.id('chatRooms'),
67+ lastReadTimestamp: v.number()
68+ })
69+ .index('by_user', ['userId'])
70+ .index('by_user_room', ['userId', 'roomId']),
71+
72+ // Typing indicators (short-lived, filtered by timestamp at read time)
73+ typing: defineTable({
74+ userId: v.id('users'),
75+ roomId: v.id('chatRooms'),
76+ updatedAt: v.number()
77+ })
78+ .index('by_room', ['roomId'])
79+ .index('by_user_room', ['userId', 'roomId']),
80+
4481 userTextPreferences: defineTable({
4582 userId: v.id('users'),
4683 defaultStyle: v.object({
M src/convex/usersInternal.ts +3 -1
@@ -73,8 +73,10 @@ export const updateStatus = internalMutation({
7373 throw new Error('User not found');
7474 }
7575
76+ // An away message keeps the user away: presence heartbeats only refresh lastSeen.
77+ const keepAway = user.awayMessage && args.status !== 'offline';
7678 await ctx.db.patch(args.id, {
77- status: args.status,
79+ status: keepAway ? 'away' : args.status,
7880 lastSeen: Date.now()
7981 });
8082
A src/lib/aim/client.ts +88 -0
@@ -0,0 +1,88 @@
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' = () =>
33+ ({}) as Omit<FunctionArgs<Q>, 'tokenHash'>
34+) {
35+ if (!convexAvailable) return null;
36+ return useQuery(fn, () => {
37+ const hash = tokenHash();
38+ const a = args();
39+ if (!hash || a === 'skip') return 'skip';
40+ return { tokenHash: hash, ...a } as FunctionArgs<Q>;
41+ });
42+}
43+
44+export function useAimClient() {
45+ const client = convexAvailable ? useConvexClient() : null;
46+
47+ function requireAuth() {
48+ const hash = tokenHash();
49+ if (!client || !hash) throw new Error('Non connecté');
50+ return { client, hash };
51+ }
52+
53+ return {
54+ client,
55+ async addBuddy(buddyId: Id<'users'>, group?: string) {
56+ const { client, hash } = requireAuth();
57+ return client.mutation(api.aim.addBuddy, { tokenHash: hash, buddyId, group });
58+ },
59+ async removeBuddy(buddyId: Id<'users'>) {
60+ const { client, hash } = requireAuth();
61+ return client.mutation(api.aim.removeBuddy, { tokenHash: hash, buddyId });
62+ },
63+ async getOrCreateDirectRoom(otherUserId: Id<'users'>) {
64+ const { client, hash } = requireAuth();
65+ return client.mutation(api.aim.getOrCreateDirectRoom, { tokenHash: hash, otherUserId });
66+ },
67+ async createGroupRoom(name: string, topic?: string) {
68+ const { client, hash } = requireAuth();
69+ return client.mutation(api.aim.createGroupRoom, { tokenHash: hash, name, topic });
70+ },
71+ async setAwayMessage(awayMessage?: string) {
72+ const { client, hash } = requireAuth();
73+ return client.mutation(api.aim.setAwayMessage, { tokenHash: hash, awayMessage });
74+ },
75+ async updateProfile(patch: { profile?: string }) {
76+ const { client, hash } = requireAuth();
77+ return client.mutation(api.aim.updateProfile, { tokenHash: hash, ...patch });
78+ },
79+ async markRoomRead(roomId: Id<'chatRooms'>) {
80+ const { client, hash } = requireAuth();
81+ return client.mutation(api.aim.markRoomRead, { tokenHash: hash, roomId });
82+ },
83+ async setTyping(roomId: Id<'chatRooms'>, typing: boolean) {
84+ const { client, hash } = requireAuth();
85+ return client.mutation(api.aim.setTyping, { tokenHash: hash, roomId, typing });
86+ }
87+ };
88+}
A src/lib/aim/smileys.ts +74 -0
@@ -0,0 +1,74 @@
1+/**
2+ * AIM "Yellow Smiley" set (AIM 4.x/5.x). Typed codes become the original
3+ * 19×19 GIFs from /static/smileys (local-only, AOL artwork). When a GIF is
4+ * missing the browser shows the img alt text, which is the Unicode emoji.
5+ */
6+export interface Smiley {
7+ file: string;
8+ codes: string[];
9+ label: string;
10+ emoji: string;
11+}
12+
13+export const SMILEYS: Smiley[] = [
14+ { file: '01', codes: [':-)', ':)'], label: 'Content', emoji: '🙂' },
15+ { file: '02', codes: [';-)', ';)'], label: 'Clin d’œil', emoji: '😉' },
16+ { file: '03', codes: [':-(', ':('], label: 'Triste', emoji: '🙁' },
17+ { file: '04', codes: [':-P', ':-p', ':P', ':p'], label: 'Langue', emoji: '😛' },
18+ { file: '05', codes: ['=-O', '=-o'], label: 'Surpris', emoji: '😮' },
19+ { file: '06', codes: [':-*'], label: 'Bisou', emoji: '😘' },
20+ { file: '07', codes: ['>:o'], label: 'Crie', emoji: '😠' },
21+ { file: '08', codes: [':-D', ':D'], label: 'Rire', emoji: '😁' },
22+ { file: '09', codes: [':-$'], label: 'Radin', emoji: '🤑' },
23+ { file: '10', codes: [':-!'], label: 'Gaffe', emoji: '😬' },
24+ { file: '11', codes: [':-['], label: 'Gêné', emoji: '😳' },
25+ { file: '12', codes: ['O:-)'], label: 'Innocent', emoji: '😇' },
26+ { file: '13', codes: [':-\\'], label: 'Indécis', emoji: '😕' },
27+ { file: '14', codes: [":'("], label: 'Pleure', emoji: '😢' },
28+ { file: '15', codes: [':-X', ':-x'], label: 'Bouche cousue', emoji: '🤐' },
29+ { file: '16', codes: ['8-)'], label: 'Cool', emoji: '😎' }
30+];
31+
32+const escape = (text: string) =>
33+ text
34+ .replace(/&/g, '&amp;')
35+ .replace(/</g, '&lt;')
36+ .replace(/>/g, '&gt;')
37+ .replace(/"/g, '&quot;')
38+ .replace(/'/g, '&#039;');
39+
40+// Codes are matched on already-escaped HTML, so match their escaped form.
41+const byEscapedCode = new Map<string, Smiley>();
42+for (const smiley of SMILEYS)
43+ for (const code of smiley.codes) byEscapedCode.set(escape(code), smiley);
44+
45+const pattern = new RegExp(
46+ // Longest first so "O:-)" wins over ":-)"; codes must not touch letters/digits.
47+ `(^|[^\\w])(${[...byEscapedCode.keys()]
48+ .sort((a, b) => b.length - a.length)
49+ .map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
50+ .join('|')})(?=$|[^\\w])`,
51+ 'g'
52+);
53+
54+export function smileyImg(smiley: Smiley) {
55+ return `<img class="aim-smiley" src="/smileys/${smiley.file}.gif" alt="${smiley.emoji}" title="${escape(smiley.codes[0])}" width="19" height="19" draggable="false">`;
56+}
57+
58+/**
59+ * Replace smiley codes in escaped HTML text. Only text between tags is
60+ * touched so attributes (style="color: …") are never rewritten.
61+ */
62+export function replaceSmileys(html: string): string {
63+ return html
64+ .split(/(<[^>]*>)/)
65+ .map((part) =>
66+ part.startsWith('<')
67+ ? part
68+ : part.replace(pattern, (_, before: string, code: string) => {
69+ const smiley = byEscapedCode.get(code);
70+ return smiley ? before + smileyImg(smiley) : _;
71+ })
72+ )
73+ .join('');
74+}
A src/lib/aim/sounds.svelte.ts +290 -0
@@ -0,0 +1,290 @@
1+/**
2+ * AIM sound effects.
3+ *
4+ * If a sample exists in /static/sounds/<name>.(mp3|wav|ogg) it is played;
5+ * otherwise a Web Audio sketch is synthesized. No audio files are shipped
6+ * in the repository so the project stays free of copyrighted samples.
7+ * Each synthesized sound evokes the original:
8+ * - doorOpen : "buddy in" — creak + soft thud
9+ * - doorSlam : "buddy out" — sharp slam
10+ * - imReceive : incoming IM "ding"
11+ * - imSend : outgoing IM "tick"
12+ * - welcome : sign-on chime
13+ * - modem : dial-up handshake (long, for the sign-on screen)
14+ */
15+import { browser } from '$app/environment';
16+
17+export type AimSound = 'doorOpen' | 'doorSlam' | 'imReceive' | 'imSend' | 'welcome' | 'modem';
18+
19+const STORAGE_KEY = 'pdraim-sounds-enabled';
20+
21+let ctx: AudioContext | null = null;
22+// Reactive so every sound toggle (taskbar, preferences) shows the same value.
23+const sound = $state({ enabled: browser ? localStorage.getItem(STORAGE_KEY) !== 'false' : true });
24+
25+export function soundsEnabled() {
26+ return sound.enabled;
27+}
28+
29+export function setSoundsEnabled(value: boolean) {
30+ sound.enabled = value;
31+ if (browser) localStorage.setItem(STORAGE_KEY, String(value));
32+}
33+
34+function getContext(): AudioContext | null {
35+ if (!browser) return null;
36+ if (!ctx) {
37+ try {
38+ ctx = new AudioContext();
39+ } catch {
40+ return null;
41+ }
42+ }
43+ if (ctx.state === 'suspended') void ctx.resume();
44+ return ctx;
45+}
46+
47+/** Call once from a user gesture so the browser allows playback. */
48+export function unlockAudio() {
49+ if (getContext()) preloadSounds();
50+}
51+
52+function noiseBuffer(ac: AudioContext, seconds: number) {
53+ const buffer = ac.createBuffer(1, Math.floor(ac.sampleRate * seconds), ac.sampleRate);
54+ const data = buffer.getChannelData(0);
55+ for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
56+ return buffer;
57+}
58+
59+function tone(
60+ ac: AudioContext,
61+ dest: AudioNode,
62+ opts: {
63+ type?: OscillatorType;
64+ freq: number;
65+ freqEnd?: number;
66+ start: number;
67+ duration: number;
68+ gain?: number;
69+ attack?: number;
70+ }
71+) {
72+ const osc = ac.createOscillator();
73+ const g = ac.createGain();
74+ osc.type = opts.type ?? 'sine';
75+ osc.frequency.setValueAtTime(opts.freq, opts.start);
76+ if (opts.freqEnd) {
77+ osc.frequency.exponentialRampToValueAtTime(opts.freqEnd, opts.start + opts.duration);
78+ }
79+ const peak = opts.gain ?? 0.3;
80+ const attack = opts.attack ?? 0.005;
81+ g.gain.setValueAtTime(0.0001, opts.start);
82+ g.gain.exponentialRampToValueAtTime(peak, opts.start + attack);
83+ g.gain.exponentialRampToValueAtTime(0.0001, opts.start + opts.duration);
84+ osc.connect(g).connect(dest);
85+ osc.start(opts.start);
86+ osc.stop(opts.start + opts.duration + 0.05);
87+}
88+
89+function thud(ac: AudioContext, dest: AudioNode, start: number, gain = 0.8, duration = 0.18) {
90+ // Low body knock: filtered noise burst + pitched sine drop
91+ const src = ac.createBufferSource();
92+ src.buffer = noiseBuffer(ac, duration);
93+ const filter = ac.createBiquadFilter();
94+ filter.type = 'lowpass';
95+ filter.frequency.setValueAtTime(900, start);
96+ filter.frequency.exponentialRampToValueAtTime(120, start + duration);
97+ const g = ac.createGain();
98+ g.gain.setValueAtTime(gain, start);
99+ g.gain.exponentialRampToValueAtTime(0.0001, start + duration);
100+ src.connect(filter).connect(g).connect(dest);
101+ src.start(start);
102+ tone(ac, dest, {
103+ freq: 160,
104+ freqEnd: 45,
105+ start,
106+ duration: duration * 0.9,
107+ gain: gain * 0.7,
108+ attack: 0.002
109+ });
110+}
111+
112+function creak(ac: AudioContext, dest: AudioNode, start: number, duration = 0.35) {
113+ // Hinge creak: sawtooth wobble with resonant bandpass
114+ const osc = ac.createOscillator();
115+ osc.type = 'sawtooth';
116+ osc.frequency.setValueAtTime(420, start);
117+ osc.frequency.linearRampToValueAtTime(560, start + duration * 0.6);
118+ osc.frequency.linearRampToValueAtTime(380, start + duration);
119+ const lfo = ac.createOscillator();
120+ lfo.frequency.value = 28;
121+ const lfoGain = ac.createGain();
122+ lfoGain.gain.value = 60;
123+ lfo.connect(lfoGain).connect(osc.frequency);
124+ const filter = ac.createBiquadFilter();
125+ filter.type = 'bandpass';
126+ filter.frequency.value = 1400;
127+ filter.Q.value = 6;
128+ const g = ac.createGain();
129+ g.gain.setValueAtTime(0.0001, start);
130+ g.gain.exponentialRampToValueAtTime(0.12, start + 0.04);
131+ g.gain.exponentialRampToValueAtTime(0.0001, start + duration);
132+ osc.connect(filter).connect(g).connect(dest);
133+ osc.start(start);
134+ lfo.start(start);
135+ osc.stop(start + duration + 0.05);
136+ lfo.stop(start + duration + 0.05);
137+}
138+
139+const players: Record<AimSound, (ac: AudioContext, dest: AudioNode, t: number) => void> = {
140+ doorOpen(ac, dest, t) {
141+ creak(ac, dest, t, 0.32);
142+ thud(ac, dest, t + 0.3, 0.5, 0.14);
143+ },
144+ doorSlam(ac, dest, t) {
145+ thud(ac, dest, t, 1.0, 0.22);
146+ // door frame rattle
147+ tone(ac, dest, {
148+ type: 'square',
149+ freq: 90,
150+ freqEnd: 60,
151+ start: t + 0.02,
152+ duration: 0.12,
153+ gain: 0.12
154+ });
155+ },
156+ imReceive(ac, dest, t) {
157+ // Two-note ding, bright
158+ tone(ac, dest, { type: 'triangle', freq: 1046, start: t, duration: 0.18, gain: 0.25 });
159+ tone(ac, dest, { type: 'sine', freq: 1568, start: t + 0.06, duration: 0.32, gain: 0.2 });
160+ },
161+ imSend(ac, dest, t) {
162+ tone(ac, dest, { type: 'triangle', freq: 880, start: t, duration: 0.09, gain: 0.18 });
163+ },
164+ welcome(ac, dest, t) {
165+ // Ascending three-note chime
166+ tone(ac, dest, { freq: 523, start: t, duration: 0.25, gain: 0.22 });
167+ tone(ac, dest, { freq: 659, start: t + 0.14, duration: 0.25, gain: 0.22 });
168+ tone(ac, dest, { freq: 784, start: t + 0.28, duration: 0.5, gain: 0.25 });
169+ },
170+ modem(ac, dest, t) {
171+ // Dial tone
172+ tone(ac, dest, { freq: 350, start: t, duration: 0.5, gain: 0.08 });
173+ tone(ac, dest, { freq: 440, start: t, duration: 0.5, gain: 0.08 });
174+ // DTMF-ish dialing
175+ const digits = [
176+ [697, 1209],
177+ [770, 1336],
178+ [852, 1477],
179+ [941, 1336],
180+ [697, 1477],
181+ [770, 1209],
182+ [852, 1336]
183+ ];
184+ digits.forEach(([lo, hi], i) => {
185+ const s = t + 0.6 + i * 0.13;
186+ tone(ac, dest, { freq: lo, start: s, duration: 0.09, gain: 0.08 });
187+ tone(ac, dest, { freq: hi, start: s, duration: 0.09, gain: 0.08 });
188+ });
189+ // Handshake: answer tone, then chirps
190+ const h = t + 1.7;
191+ tone(ac, dest, { freq: 2100, start: h, duration: 0.6, gain: 0.07 });
192+ tone(ac, dest, {
193+ type: 'square',
194+ freq: 1200,
195+ freqEnd: 2400,
196+ start: h + 0.7,
197+ duration: 0.35,
198+ gain: 0.05
199+ });
200+ tone(ac, dest, {
201+ type: 'square',
202+ freq: 2400,
203+ freqEnd: 1200,
204+ start: h + 1.1,
205+ duration: 0.35,
206+ gain: 0.05
207+ });
208+ // Noise burst (training)
209+ const src = ac.createBufferSource();
210+ src.buffer = noiseBuffer(ac, 1.2);
211+ const filter = ac.createBiquadFilter();
212+ filter.type = 'bandpass';
213+ filter.frequency.value = 1800;
214+ filter.Q.value = 0.7;
215+ const g = ac.createGain();
216+ g.gain.setValueAtTime(0.0001, h + 1.5);
217+ g.gain.exponentialRampToValueAtTime(0.09, h + 1.6);
218+ g.gain.exponentialRampToValueAtTime(0.0001, h + 2.7);
219+ src.connect(filter).connect(g).connect(dest);
220+ src.start(h + 1.5);
221+ }
222+};
223+
224+export const SOUND_DURATIONS_MS: Record<AimSound, number> = {
225+ doorOpen: 500,
226+ doorSlam: 300,
227+ imReceive: 400,
228+ imSend: 120,
229+ welcome: 800,
230+ modem: 4500
231+};
232+
233+// Sample cache: one in-flight/resolved promise per sound (null = no file available).
234+// Not UI state, so a plain Map is intended.
235+// eslint-disable-next-line svelte/prefer-svelte-reactivity
236+const samples = new Map<AimSound, Promise<AudioBuffer | null>>();
237+const SAMPLE_EXTENSIONS = ['flac', 'mp3', 'wav', 'ogg'];
238+
239+function loadSample(ac: AudioContext, name: AimSound): Promise<AudioBuffer | null> {
240+ let pending = samples.get(name);
241+ if (!pending) {
242+ pending = (async () => {
243+ for (const ext of SAMPLE_EXTENSIONS) {
244+ try {
245+ const res = await fetch(`/sounds/${name}.${ext}`, { cache: 'force-cache' });
246+ // Skip HTML fallbacks; some servers send .flac without an audio/* type
247+ if (!res.ok || (res.headers.get('content-type') ?? '').startsWith('text/')) continue;
248+ return await ac.decodeAudioData(await res.arrayBuffer());
249+ } catch {
250+ // try next extension
251+ }
252+ }
253+ return null;
254+ })();
255+ samples.set(name, pending);
256+ }
257+ return pending;
258+}
259+
260+/** Warm the sample cache so the first playback is not delayed by a fetch. */
261+export function preloadSounds() {
262+ const ac = getContext();
263+ if (!ac) return;
264+ for (const name of Object.keys(players) as AimSound[]) void loadSample(ac, name);
265+}
266+
267+export function playSound(name: AimSound) {
268+ if (!sound.enabled) return;
269+ const ac = getContext();
270+ if (!ac) return;
271+ // Always wait for the sample probe so the first play never falls back to the
272+ // synthesized sketch while the real file is still downloading.
273+ void loadSample(ac, name).then((buffer) => {
274+ if (buffer) {
275+ const src = ac.createBufferSource();
276+ src.buffer = buffer;
277+ src.connect(ac.destination);
278+ src.start();
279+ return;
280+ }
281+ const master = ac.createGain();
282+ master.gain.value = 0.9;
283+ master.connect(ac.destination);
284+ try {
285+ players[name](ac, master, ac.currentTime);
286+ } catch (err) {
287+ console.warn('Sound playback failed', err);
288+ }
289+ });
290+}
D src/lib/components/aim-login.svelte +0 -626
@@ -1,626 +0,0 @@
1-<!-- AIM Login Component Updated for Svelte5 with Sign In/Sign Up Tabs -->
2-<script lang="ts">
3- import { draggable } from '$lib/actions/draggable';
4- import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
5- import { invalidate } from '$app/navigation';
6- import { chatState } from '$lib/states/chat.svelte';
7- import { createSafeUser } from '$lib/types/chat';
8- import { onMount } from 'svelte';
9- import PasswordStrengthIndicator from './password-strength-indicator.svelte';
10- import {
11- loginSchema,
12- createRegistrationSchema,
13- DEFAULT_PASSWORD_CONSTRAINTS,
14- type PasswordConstraints
15- } from '../validation/password';
16- import { z } from 'zod';
17-
18- let {
19- showAuth = $bindable(),
20- onLoginSuccess,
21- activeTab = $bindable<'signin' | 'signup'>('signin')
22- } = $props<{
23- showAuth?: boolean;
24- onLoginSuccess?: () => void;
25- activeTab?: 'signin' | 'signup';
26- }>();
27-
28- // Sign In state
29- let siUsername = $state('');
30- let siPassword = $state('');
31- let error = $state('');
32- let loginStatus = $state('idle');
33-
34- // Sign Up state
35- let suUsername = $state('');
36- let suPassword = $state('');
37- let suConfirmPassword = $state('');
38- let captchaAnswer = $state('');
39- let signupError = $state('');
40- let signupStatus = $state('idle');
41- let suTurnstileToken = $state('');
42-
43- // Password validation state
44- let passwordConstraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS;
45- let showPasswordStrength = $state(false);
46-
47- // Added dragging state and handler similar to chat-room
48- let windowX = $state(0);
49- let windowY = $state(0);
50-
51- // Initialize window position on mount
52- onMount(() => {
53- if (window.innerWidth > 768) {
54- // Desktop mode
55- windowX = window.innerWidth * 0.2;
56- windowY = window.innerHeight * 0.2;
57- }
58- });
59-
60- function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
61- windowX = event.detail.x;
62- windowY = event.detail.y;
63- }
64-
65- function handleClose() {
66- console.debug('Closing AIM Login component.');
67- showAuth = false;
68- }
69-
70- async function handleSigninSubmit() {
71- error = '';
72- loginStatus = 'loading';
73-
74- // Validate login data
75- const validationError = validateLoginData();
76- if (validationError) {
77- error = validationError;
78- loginStatus = 'error';
79- return;
80- }
81-
82- console.debug('Attempting login for', siUsername);
83- try {
84- const res = await fetch('/api/session/login', {
85- method: 'POST',
86- headers: { 'Content-Type': 'application/json' },
87- body: JSON.stringify({
88- username: siUsername,
89- password: siPassword
90- })
91- });
92-
93- const data = await res.json();
94- if (!res.ok) {
95- error = data.error || 'Login failed.';
96- console.debug('Login failed:', data.error);
97- loginStatus = 'error';
98- return;
99- }
100-
101- console.debug('Login succeeded:', {
102- ...data,
103- user: data.user ? { ...data.user, password: '[REDACTED]' } : null
104- });
105- await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
106- loginStatus = 'success';
107-
108- // Refresh session-dependent data
109- await invalidate('app:session');
110- await invalidate('app:chat');
111-
112- // Call the callback prop if provided
113- onLoginSuccess?.(data.user ? createSafeUser(data.user) : null);
114- // Auto close login window after 3 seconds on successful login
115- setTimeout(() => {
116- console.debug('Auto closing AIM Login component after successful login');
117- handleClose();
118- }, 3000);
119-
120- return;
121- } catch (err) {
122- console.debug('Login error:', err);
123- error = 'Login error. Please try again.';
124- loginStatus = 'error';
125- }
126- }
127-
128- // Validation using Zod schemas
129- function validateLoginData() {
130- try {
131- loginSchema.parse({ username: siUsername.trim(), password: siPassword.trim() });
132- return null;
133- } catch (err) {
134- if (err instanceof z.ZodError) {
135- return err.issues?.[0]?.message || 'Invalid input';
136- }
137- return 'Invalid input';
138- }
139- }
140-
141- function validateSignupData() {
142- try {
143- const registrationSchema = createRegistrationSchema(passwordConstraints);
144- registrationSchema.parse({
145- suUsername: suUsername.trim(),
146- suPassword: suPassword.trim(),
147- suConfirmPassword: suConfirmPassword.trim(),
148- captchaAnswer: captchaAnswer.trim(),
149- turnstileToken: suTurnstileToken
150- });
151- return null;
152- } catch (err) {
153- if (err instanceof z.ZodError) {
154- return err.issues?.[0]?.message || 'Invalid input';
155- }
156- return 'Invalid input';
157- }
158- }
159-
160- async function handleSignupSubmit() {
161- signupError = '';
162- signupStatus = 'loading';
163-
164- // Client-side validations before submitting the form.
165- const validationError = validateSignupData();
166- if (validationError) {
167- signupError = validationError;
168- signupStatus = 'error';
169- return;
170- }
171-
172- // Additional check for captcha
173- if (!captchaAnswer) {
174- signupError = 'Please complete all fields including the PDR question';
175- signupStatus = 'error';
176- return;
177- }
178-
179- console.debug('Attempting registration for', suUsername);
180- try {
181- const res = await fetch('/api/register', {
182- method: 'POST',
183- headers: { 'Content-Type': 'application/json' },
184- body: JSON.stringify({
185- suUsername,
186- suPassword,
187- suConfirmPassword,
188- captchaAnswer,
189- turnstileToken: suTurnstileToken
190- })
191- });
192-
193- const data = (await res.json()) as RegisterResponse;
194- if (!res.ok) {
195- const errorData = data as RegisterResponseError;
196- signupError = errorData.error || 'Registration failed.';
197- signupStatus = 'error';
198- return;
199- }
200- console.debug('Registration succeeded:', data);
201- signupStatus = 'success';
202-
203- // Auto login after registration.
204- await handleAutoLoginSignup();
205- return;
206- } catch (err) {
207- console.debug('Registration error', err);
208- signupError = 'Registration error. Please try again.';
209- signupStatus = 'error';
210- }
211- }
212-
213- async function handleAutoLoginSignup() {
214- try {
215- const res = await fetch('/api/session/login', {
216- method: 'POST',
217- headers: { 'Content-Type': 'application/json' },
218- body: JSON.stringify({ username: suUsername, password: suPassword })
219- });
220- const data = await res.json();
221- if (!res.ok) {
222- signupError = data.error || 'Login failed after registration.';
223- signupStatus = 'error';
224- return;
225- }
226- console.debug('Auto login after registration succeeded:', data);
227- await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
228- await invalidate('app:session');
229- await invalidate('app:chat');
230- onLoginSuccess?.(data.user ? createSafeUser(data.user) : null);
231- setTimeout(() => {
232- console.debug('Auto closing AIM Login component after auto login on registration');
233- handleClose();
234- }, 3000);
235- } catch (err) {
236- console.debug('Auto login error:', err);
237- signupError = 'Auto login error. Please try to login manually.';
238- signupStatus = 'error';
239- }
240- }
241-
242- function switchTab(tab: 'signin' | 'signup') {
243- activeTab = tab;
244- error = '';
245- signupError = '';
246- }
247-
248- function handleKeydown(event: KeyboardEvent) {
249- if (event.key === 'Escape') {
250- console.debug('Escape key pressed, closing AIM Login component.');
251- handleClose();
252- }
253- }
254-</script>
255-
256-<svelte:window onkeydown={handleKeydown} />
257-
258-<!-- Updated outer div to handle dragging with inline style and drag event -->
259-<div
260- class="login-window window"
261- use:draggable={{ handle: '.title-bar' }}
262- ondragmove={handleDragMove}
263- style="left: {windowX}px; top: {windowY}px;"
264->
265- <div class="title-bar">
266- <div class="title-bar-text">
267- <img
268- src="/desktop/aim-desktop-icon.png"
269- alt="AIM"
270- class="title-icon"
271- width="16"
272- height="16"
273- />
274- {activeTab === 'signin' ? 'Connexion' : 'Inscription'}
275- </div>
276- <div class="title-bar-controls">
277- <button aria-label="Close" onclick={handleClose}>×</button>
278- </div>
279- </div>
280-
281- <div class="tabs">
282- <button class:active={activeTab === 'signin'} onclick={() => switchTab('signin')}
283- >Connexion</button
284- >
285- <button class:active={activeTab === 'signup'} onclick={() => switchTab('signup')}
286- >Inscription</button
287- >
288- </div>
289-
290- <div class="window-content">
291- {#if activeTab === 'signin'}
292- <form onsubmit={handleSigninSubmit}>
293- <div class="form-group">
294- <label for="si-username">Nom d'utilisateur</label>
295- <input
296- type="text"
297- id="si-username"
298- bind:value={siUsername}
299- placeholder="Nom d'utilisateur"
300- />
301- </div>
302- <div class="form-group">
303- <label for="si-password">Mot de passe</label>
304- <input
305- type="password"
306- id="si-password"
307- bind:value={siPassword}
308- placeholder="Mot de passe"
309- />
310- </div>
311- {#if error}
312- <div class="error">{error}</div>
313- {/if}
314- <div class="button-bar">
315- <button
316- type="submit"
317- class="icon-button {loginStatus === 'success'
318- ? 'success-border'
319- : loginStatus === 'error'
320- ? 'error-border'
321- : ''}"
322- >
323- {#if loginStatus === 'loading'}
324- <svg width="24" height="24" viewBox="0 0 24 24">
325- <circle
326- cx="12"
327- cy="12"
328- r="10"
329- stroke="currentColor"
330- stroke-width="2"
331- fill="none"
332- opacity="0.25"
333- ></circle>
334- <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none">
335- <animateTransform
336- attributeName="transform"
337- type="rotate"
338- values="0 12 12;360 12 12"
339- dur="1s"
340- repeatCount="indefinite"
341- />
342- </path>
343- </svg>
344- {:else if loginStatus === 'success'}
345- <svg width="24" height="24" viewBox="0 0 24 24">
346- <path
347- d="M20 6L9 17l-5-5"
348- stroke="green"
349- stroke-width="2"
350- fill="none"
351- stroke-linecap="round"
352- stroke-linejoin="round"
353- />
354- </svg>
355- {:else if loginStatus === 'error'}
356- <svg width="24" height="24" viewBox="0 0 24 24">
357- <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none" />
358- <line
359- x1="12"
360- y1="8"
361- x2="12"
362- y2="13"
363- stroke="red"
364- stroke-width="2"
365- stroke-linecap="round"
366- />
367- <circle cx="12" cy="17" r="1" fill="red" />
368- </svg>
369- {:else}
370- <svg
371- width="24"
372- height="24"
373- viewBox="0 0 24 24"
374- fill="none"
375- xmlns="http://www.w3.org/2000/svg"
376- >
377- <path
378- d="M12 4C9.24 4 7 6.24 7 9c0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.76-2.24-5-5-5zm0 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 10c-3.33 0-7 1.67-7 5v1h14v-1c0-3.33-3.67-5-7-5zm0 2c2.67 0 5 1.33 5 3H7c0-1.67 2.33-3 5-3z"
379- fill="currentColor"
380- />
381- </svg>
382- {/if}
383- Se connecter
384- </button>
385- </div>
386- </form>
387- {:else}
388- <form onsubmit={handleSignupSubmit}>
389- <div class="form-group">
390- <label for="su-username">Nom d'utilisateur</label>
391- <input
392- type="text"
393- id="su-username"
394- bind:value={suUsername}
395- placeholder="Choisissez un nom d'utilisateur"
396- />
397- </div>
398- <div class="form-group">
399- <label for="su-password">Mot de passe</label>
400- <input
401- type="password"
402- id="su-password"
403- bind:value={suPassword}
404- placeholder="Mot de passe"
405- onfocus={() => (showPasswordStrength = true)}
406- />
407- {#if showPasswordStrength}
408- <PasswordStrengthIndicator
409- password={suPassword}
410- constraints={passwordConstraints}
411- showDetails={true}
412- />
413- {/if}
414- </div>
415- <div class="form-group">
416- <label for="su-confirm-password">Confirmer le mot de passe</label>
417- <input
418- type="password"
419- id="su-confirm-password"
420- bind:value={suConfirmPassword}
421- placeholder="Confirmer le mot de passe"
422- />
423- </div>
424- <div class="form-group">
425- <label for="captcha">Quelle est la signification de PDR ?</label>
426- <input
427- type="text"
428- id="captcha"
429- bind:value={captchaAnswer}
430- placeholder="Entrez votre réponse"
431- />
432- </div>
433- {#if signupError}
434- <div class="error">{signupError}</div>
435- {/if}
436- <div class="button-bar">
437- <button
438- type="submit"
439- class="icon-button {signupStatus === 'success'
440- ? 'success-border'
441- : signupStatus === 'error'
442- ? 'error-border'
443- : ''}"
444- >
445- {#if signupStatus === 'loading'}
446- <svg width="24" height="24" viewBox="0 0 24 24">
447- <circle
448- cx="12"
449- cy="12"
450- r="10"
451- stroke="currentColor"
452- stroke-width="2"
453- fill="none"
454- opacity="0.25"
455- ></circle>
456- <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none">
457- <animateTransform
458- attributeName="transform"
459- type="rotate"
460- values="0 12 12;360 12 12"
461- dur="1s"
462- repeatCount="indefinite"
463- ></animateTransform>
464- </path>
465- </svg>
466- {:else if signupStatus === 'success'}
467- <svg width="24" height="24" viewBox="0 0 24 24">
468- <path
469- d="M20 6L9 17l-5-5"
470- stroke="green"
471- stroke-width="2"
472- fill="none"
473- stroke-linecap="round"
474- stroke-linejoin="round"
475- ></path>
476- </svg>
477- {:else if signupStatus === 'error'}
478- <svg width="24" height="24" viewBox="0 0 24 24">
479- <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"></circle>
480- <line
481- x1="12"
482- y1="8"
483- x2="12"
484- y2="13"
485- stroke="red"
486- stroke-width="2"
487- stroke-linecap="round"
488- ></line>
489- <circle cx="12" cy="17" r="1" fill="red"></circle>
490- </svg>
491- {:else}
492- <svg
493- width="24"
494- height="24"
495- viewBox="0 0 24 24"
496- fill="none"
497- xmlns="http://www.w3.org/2000/svg"
498- >
499- <path
500- d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
501- fill="currentColor"
502- ></path>
503- </svg>
504- {/if}
505- S'inscrire
506- </button>
507- </div>
508- </form>
509- {/if}
510- </div>
511-</div>
512-
513-<style>
514- .login-window {
515- width: 300px;
516- background: #ece9d8;
517- /* Changed to fixed positioning for correct dragging */
518- position: fixed;
519- }
520-
521- @media (max-width: 768px) {
522- .login-window {
523- width: 100% !important;
524- height: 100% !important;
525- top: 0 !important;
526- left: 0 !important;
527- right: 0 !important;
528- bottom: 0 !important;
529- margin: 0 !important;
530- border-radius: 0 !important;
531- transform: none !important;
532- }
533-
534- .window-content {
535- height: calc(100% - 80px); /* Account for title bar and tabs */
536- overflow-y: auto;
537- }
538-
539- form {
540- max-width: 400px;
541- margin: 0 auto;
542- padding: 1rem;
543- }
544- }
545-
546- .title-bar {
547- background: linear-gradient(180deg, #0054e3 0%, #0047ab 100%);
548- padding: 4px 6px;
549- display: flex;
550- justify-content: space-between;
551- align-items: center;
552- color: white;
553- font-weight: bold;
554- user-select: none;
555- position: relative;
556- }
557- .title-bar-text {
558- display: flex;
559- align-items: center;
560- gap: 4px;
561- font-size: 0.875rem;
562- }
563-
564- .tabs {
565- padding: 0 3px;
566- display: flex;
567- border-bottom: 1px solid #0054e3;
568- }
569- .tabs button {
570- flex: 1;
571- padding: 8px;
572- border: none;
573- background: #ece9d8;
574- font-size: 1rem;
575- cursor: pointer;
576- }
577- .tabs button.active {
578- background: #fff;
579- border-bottom: 2px solid #0054e3;
580- font-weight: bold;
581- }
582- .window-content {
583- padding: 16px;
584- }
585- .form-group {
586- margin-bottom: 16px;
587- }
588- .form-group label {
589- display: block;
590- margin-bottom: 4px;
591- font-size: 1rem;
592- }
593- .form-group input {
594- width: 100%;
595- padding: 4px;
596- font-size: 1rem;
597- font-family: Arial, Verdana, Tahoma, sans-serif;
598- }
599- .button-bar {
600- display: flex;
601- justify-content: center;
602- margin-top: 20px;
603- }
604- .icon-button {
605- display: flex;
606- flex-direction: column;
607- align-items: center;
608- gap: 4px;
609- background: none;
610- border: none;
611- cursor: pointer;
612- font-size: 1rem;
613- color: black;
614- }
615- .error {
616- color: red;
617- font-size: 1rem;
618- margin-top: 8px;
619- }
620- .success-border {
621- border: 2px solid green;
622- }
623- .error-border {
624- border: 2px solid red;
625- }
626-</style>
M src/lib/components/chat-room.svelte +642 -698
@@ -9,14 +9,13 @@
99 import { browser } from '$app/environment';
1010 import { env } from '$env/dynamic/public';
1111 import { api } from '$lib/api/client';
12- import { draggable } from '$lib/actions/draggable';
13- import { resizable } from '$lib/actions/resizable';
14- import { maximizable } from '$lib/actions/maximizable';
15- import { minimizable, type MinimizableNode } from '$lib/actions/minimizable';
1612 import LoadingButton from './ui/button-loading.svelte';
1713 import Tooltip from './ui/tooltip.svelte';
1814 import LoadingDots from './ui/loading-dots.svelte';
19- import AimLogin from './aim-login.svelte';
15+ import { desktop } from '$lib/states/desktop.svelte';
16+ import { authQuery, tokenHash, useAimClient } from '$lib/aim/client';
17+ import { PUBLIC_PREVIEW_MESSAGES } from '$lib/validation/message';
18+ import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
2019 import FormattedMessage from './formatted-message.svelte';
2120 import TextFormattingToolbar from './text-formatting-toolbar.svelte';
2221 import MentionPicker from './mention-picker.svelte';
@@ -31,34 +30,50 @@
3130 } from '../utils/mention';
3231 import { DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting';
3332 import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';
34- import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state';
33+ import { debounce } from '$lib/utils/chat-window-state';
3534 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
3635
3736 // Props
38- let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props<{
39- showChatRoom?: boolean;
37+ let {
38+ roomId: requestedRoomId = null,
39+ initialTextStyle = DEFAULT_TEXT_STYLE,
40+ showUserList = false
41+ } = $props<{
42+ roomId?: Id<'chatRooms'> | null;
4043 initialTextStyle?: TextStyle;
44+ /** Mobile only: whether the people list is expanded */
45+ showUserList?: boolean;
4146 }>();
4247
4348 const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL);
49+ const aim = useAimClient();
4450
4551 // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============
4652
47- // Subscribe to default room (to get room ID)
48- const defaultRoomQuery = isClient ? useQuery(convexApi.queries.getDefaultRoomPublic, {}) : null;
53+ // Subscribe to default room (to get room ID) when no explicit room was requested
54+ // svelte-ignore state_referenced_locally
55+ const defaultRoomQuery =
56+ isClient && !requestedRoomId ? useQuery(convexApi.queries.getDefaultRoomPublic, {}) : null;
4957
5058 // Subscribe to users (buddy list) - real-time updates
5159 const usersQuery = isClient ? useQuery(convexApi.queries.getUsersPublic, {}) : null;
5260
53- // Room ID from Convex query
54- const roomId = $derived(defaultRoomQuery?.data?.id as Id<'chatRooms'> | undefined);
61+ // Room ID: explicit prop or the default room
62+ const roomId = $derived(
63+ (requestedRoomId ?? defaultRoomQuery?.data?.id) as Id<'chatRooms'> | undefined
64+ );
5565
56- // Subscribe to messages - only when room ID is available
57- const messagesQuery = $derived(
58- roomId && isClient
59- ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! }))
60- : null
66+ // Subscribe to messages - skipped until the room ID is available.
67+ // Members get the full live feed; visitors only the public preview of the default room.
68+ const memberMessagesQuery = authQuery(convexApi.queries.getMessages, () =>
69+ roomId ? { roomId } : 'skip'
6170 );
71+ const publicMessagesQuery = isClient
72+ ? useQuery(convexApi.queries.getMessagesPublic, () =>
73+ roomId && !tokenHash() ? { roomId } : 'skip'
74+ )
75+ : null;
76+ const messagesQuery = $derived(tokenHash() ? memberMessagesQuery : publicMessagesQuery);
6277
6378 const convexClient = isClient ? useConvexClient() : null;
6479
@@ -71,11 +86,19 @@
7186 nickname: u.nickname,
7287 status: u.status as SafeUser['status'],
7388 avatarUrl: u.avatarUrl ?? null,
74- lastSeen: u.lastSeen ?? null
89+ lastSeen: u.lastSeen ?? null,
90+ awayMessage: u.awayMessage
7591 }))
7692 .sort((a, b) => {
77- const statusOrder: Record<string, number> = { online: 0, away: 1, busy: 2, offline: 3 };
78- return (statusOrder[a.status] ?? 3) - (statusOrder[b.status] ?? 3);
93+ const statusOrder: Record<string, number> = {
94+ online: 0,
95+ idle: 1,
96+ away: 2,
97+ busy: 3,
98+ offline: 4
99+ };
100+ const diff = (statusOrder[a.status] ?? 4) - (statusOrder[b.status] ?? 4);
101+ return diff !== 0 ? diff : a.nickname.localeCompare(b.nickname);
79102 });
80103 });
81104
@@ -152,15 +175,51 @@
152175
153176 // ============ WINDOW STATE ============
154177
155- let windowWidth = $state(800);
156- let windowHeight = $state(600);
157- let windowX = $state(0);
158- let windowY = $state(0);
159178 let isMobile = $state(false);
160- let showUserList = $state(false);
161- let isMaximized = $state(false);
162- let isMinimized = $state(false);
163- let isCentered = $state(true);
179+
180+ // ============ USER LIST INTERACTIONS (AIM-style) ============
181+
182+ let selectedUserId = $state<string | null>(null);
183+ let userMenu = $state<{ x: number; y: number; user: SafeUser } | null>(null);
184+ let roomPanel = $state<HTMLDivElement | null>(null);
185+
186+ async function openImWith(user: SafeUser) {
187+ if (!currentUser || user.id === currentUser.id) return;
188+ try {
189+ const { id } = await aim.getOrCreateDirectRoom(user.id as Id<'users'>);
190+ desktop.openIm({
191+ roomId: id,
192+ otherUserId: user.id as Id<'users'>,
193+ otherNickname: user.nickname
194+ });
195+ } catch (err) {
196+ console.error('Failed to open IM', err);
197+ }
198+ }
199+
200+ function openUserMenu(event: MouseEvent, user: SafeUser) {
201+ event.preventDefault();
202+ selectedUserId = user.id;
203+ const rect = roomPanel?.getBoundingClientRect();
204+ userMenu = {
205+ x: event.clientX - (rect?.left ?? 0),
206+ y: event.clientY - (rect?.top ?? 0),
207+ user
208+ };
209+ }
210+
211+ function closeUserMenu() {
212+ userMenu = null;
213+ }
214+
215+ function statusLabel(user: SafeUser) {
216+ const label = STATUS_LABELS_FR[(user.status as AimStatus) ?? 'offline'] ?? user.status;
217+ if (user.status === 'away' && user.awayMessage) return `${label} : ${user.awayMessage}`;
218+ if (user.status === 'offline') {
219+ return `${label} — dernière connexion : ${formatFrenchRelativeTimeSafe(user.lastSeen)}`;
220+ }
221+ return label;
222+ }
164223
165224 // ============ INPUT STATE ============
166225
@@ -206,10 +265,6 @@
206265 let rateLimitWarning = $state<string | null>(null);
207266 let isSendingMessage = $state(false);
208267
209- // ============ AUTH STATE ============
210-
211- let showAuth = $state(false);
212-
213268 // ============ SCROLL STATE ============
214269
215270 let isLoadingMore = $state(false);
@@ -223,11 +278,15 @@
223278 // Visible messages (limit for non-logged-in users)
224279 const visibleMessages = $derived.by<EnrichedMessage[]>(() => {
225280 const isLoggedIn = Boolean(currentUser);
226- return isLoggedIn ? allMessages : allMessages.slice(Math.max(0, allMessages.length - 50));
281+ return isLoggedIn
282+ ? allMessages
283+ : allMessages.slice(Math.max(0, allMessages.length - PUBLIC_PREVIEW_MESSAGES));
227284 });
228285
229286 // Show registration prompt for non-logged-in users
230- const showRegistrationPrompt = $derived(!currentUser && allMessages.length > 50);
287+ const showRegistrationPrompt = $derived(
288+ !currentUser && allMessages.length >= PUBLIC_PREVIEW_MESSAGES
289+ );
231290
232291 // Character counter
233292 const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8);
@@ -241,9 +300,9 @@
241300 );
242301
243302 // User counts
244- const totalUsers = $derived(onlineUsers.length);
245303 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
246304 const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
305+ const selectedUser = $derived(onlineUsers.find((u) => u.id === selectedUserId) ?? null);
247306
248307 const mentionableUsers = $derived(
249308 onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id)
@@ -635,8 +694,6 @@
635694 pagedMessages = [...pagedMessages, ...newMessages];
636695 }
637696
638- const debouncedSaveWindowState = debounce(saveWindowState, 300);
639-
640697 // Debounced function to update the server with last read mention timestamp
641698 const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => {
642699 if (!currentUser || !browser) return;
@@ -815,10 +872,6 @@
815872
816873 // ============ HANDLERS ============
817874
818- function handleClose() {
819- showChatRoom = false;
820- }
821-
822875 function shouldHighlightMention(message: EnrichedMessage) {
823876 if (!currentUser) return false;
824877 if (message.senderId === currentUser.id) return false;
@@ -909,51 +962,6 @@
909962 }
910963 }
911964
912- function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
913- windowX = event.detail.x;
914- windowY = event.detail.y;
915- isCentered = false;
916- }
917-
918- interface MaximizableNode extends HTMLElement {
919- toggleMaximize: () => void;
920- }
921-
922- function handleMaximize(event: MouseEvent) {
923- const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;
924- if (node) {
925- if (isMinimized) {
926- const minimizableNode = node as unknown as MinimizableNode;
927- if (minimizableNode?.toggleMinimize) {
928- minimizableNode.toggleMinimize();
929- }
930- setTimeout(() => node.toggleMaximize(), 300);
931- } else {
932- node.toggleMaximize();
933- }
934- }
935- }
936-
937- function handleMaximizeEvent(
938- event: CustomEvent<{
939- isMaximized: boolean;
940- width: number;
941- height: number;
942- x: number;
943- y: number;
944- }>
945- ) {
946- isMaximized = event.detail.isMaximized;
947- windowWidth = event.detail.width;
948- windowHeight = event.detail.height;
949- windowX = event.detail.x;
950- windowY = event.detail.y;
951- }
952-
953- function handleMinimize(event: CustomEvent<{ isMinimized: boolean }>) {
954- isMinimized = event.detail.isMinimized;
955- }
956-
957965 async function handleScroll(event: Event) {
958966 const target = event.target as HTMLElement;
959967 const { scrollTop } = target;
@@ -964,18 +972,19 @@
964972 hasMoreMessages &&
965973 roomId &&
966974 chatArea &&
967- currentUser &&
968975 convexClient
969976 ) {
977+ const hash = tokenHash();
978+ if (!hash) return;
970979 isLoadingMore = true;
971980 const prevScrollHeight = chatArea.scrollHeight;
972981 const prevScrollTop = chatArea.scrollTop;
973982
974983 try {
975- const fetchLimit = currentUser ? 100 : 50;
976- const data = await convexClient.query(convexApi.queries.getMessagesPublicPage, {
984+ const data = await convexClient.query(convexApi.queries.getMessagesPage, {
985+ tokenHash: hash,
977986 roomId,
978- limit: fetchLimit,
987+ limit: 100,
979988 beforeTimestamp: oldestMessageTimestamp ?? undefined
980989 });
981990
@@ -1007,12 +1016,14 @@
10071016 }
10081017 }
10091018
1010- function openSignup() {
1011- showAuth = true;
1019+ function insertSmiley(code: string) {
1020+ const needsSpace = currentMessage.length > 0 && !currentMessage.endsWith(' ');
1021+ currentMessage = `${currentMessage}${needsSpace ? ' ' : ''}${code} `;
1022+ void tick().then(() => messageInput?.focus());
10121023 }
10131024
1014- function handleLoginSuccess() {
1015- showAuth = false;
1025+ function openSignup() {
1026+ desktop.openLogin('signup');
10161027 }
10171028
10181029 // ============ LIFECYCLE ============
@@ -1021,101 +1032,28 @@
10211032 if (!browser) return;
10221033
10231034 isMobile = window.innerWidth <= 768;
1024-
1025- if (!isMobile) {
1026- const savedState = loadWindowState();
1027- const maxWidth = window.innerWidth - 40;
1028- const maxHeight = window.innerHeight - 40;
1029-
1030- windowWidth = Math.min(savedState.width, maxWidth);
1031- windowHeight = Math.min(savedState.height, maxHeight);
1032-
1033- if (savedState.x === -1) {
1034- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
1035- } else {
1036- windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
1037- }
1038- if (savedState.y === -1) {
1039- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
1040- } else {
1041- windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
1042- }
1043-
1044- isMaximized = savedState.isMaximized;
1045- isMinimized = savedState.isMinimized;
1046- showUserList = savedState.showUserList;
1047- isCentered = savedState.isCentered;
1048- }
1049-
10501035 const handleResize = () => {
1051- const wasMobile = isMobile;
10521036 isMobile = window.innerWidth <= 768;
1053-
1054- if (isMobile) {
1055- windowWidth = window.innerWidth;
1056- windowHeight = window.innerHeight;
1057- windowX = 0;
1058- windowY = 0;
1059- } else if (wasMobile && !isMobile) {
1060- const savedState = loadWindowState();
1061- const maxWidth = window.innerWidth - 40;
1062- const maxHeight = window.innerHeight - 40;
1063- windowWidth = Math.min(savedState.width, maxWidth);
1064- windowHeight = Math.min(savedState.height, maxHeight);
1065-
1066- if (savedState.x === -1) {
1067- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
1068- } else {
1069- windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
1070- }
1071- if (savedState.y === -1) {
1072- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
1073- } else {
1074- windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
1075- }
1076- } else if (!isMobile) {
1077- const maxWidth = window.innerWidth - 40;
1078- const maxHeight = window.innerHeight - 40;
1079- windowWidth = Math.min(windowWidth, maxWidth);
1080- windowHeight = Math.min(windowHeight, maxHeight);
1081-
1082- if (isCentered) {
1083- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
1084- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
1085- } else {
1086- windowX = Math.max(0, Math.min(windowX, window.innerWidth - windowWidth));
1087- windowY = Math.max(0, Math.min(windowY, window.innerHeight - windowHeight));
1088- }
1089- }
10901037 };
1091-
1092- if (isMobile) handleResize();
1093-
10941038 window.addEventListener('resize', handleResize);
10951039
1040+ const onGlobalClick = () => closeUserMenu();
1041+ const onGlobalKey = (e: KeyboardEvent) => {
1042+ if (e.key === 'Escape') closeUserMenu();
1043+ };
1044+ window.addEventListener('click', onGlobalClick);
1045+ window.addEventListener('keydown', onGlobalKey);
1046+
10961047 return () => {
10971048 window.removeEventListener('resize', handleResize);
1049+ window.removeEventListener('click', onGlobalClick);
1050+ window.removeEventListener('keydown', onGlobalKey);
10981051 if (cooldownInterval) clearInterval(cooldownInterval);
10991052 // Clean up canvas context used for text measurement
11001053 mentionMeasureContext = null;
11011054 };
11021055 });
11031056
1104- // Save window state when it changes
1105- $effect(() => {
1106- if (!browser || isMobile) return;
1107- debouncedSaveWindowState({
1108- width: windowWidth,
1109- height: windowHeight,
1110- x: windowX,
1111- y: windowY,
1112- isMaximized,
1113- isMinimized,
1114- showUserList,
1115- isCentered
1116- });
1117- });
1118-
11191057 // Scroll selected mention option into view when selection changes
11201058 $effect(() => {
11211059 if (!mentionOpen || mentionSuggestions.length === 0) return;
@@ -1125,196 +1063,123 @@
11251063 });
11261064 </script>
11271065
1128-{#if showChatRoom}
1129- <div
1130- class="chat-window window"
1131- class:minimized={isMinimized}
1132- style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;"
1133- use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
1134- use:resizable={{
1135- enabled: !isMobile && !isMaximized && !isMinimized,
1136- minWidth: 400,
1137- minHeight: 500,
1138- maxWidth: window.innerWidth - 40,
1139- maxHeight: window.innerHeight - 40
1140- }}
1141- use:maximizable={{ enabled: !isMobile && !isMinimized, padding: 4 }}
1142- use:minimizable={{ enabled: !isMobile }}
1143- onmaximize={handleMaximizeEvent}
1144- onminimize={handleMinimize}
1145- onresizemove={(e) => {
1146- windowWidth = Math.max(400, e.detail.width);
1147- windowHeight = Math.max(500, e.detail.height);
1148- }}
1149- ondragmove={handleDragMove}
1150- >
1151- <div class="title-bar">
1152- <div
1153- class="title-bar-text"
1154- style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;"
1155- >
1156- Pdr Aim {#if mentionUnreadCount > 0}({mentionUnreadCount})
1157- {/if}{#if currentUser}
1158- - {currentUser.nickname}{:else}
1159- - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
1160- {#if connectionError && !isMinimized}
1161- <span class="connection-error">⚠️ {connectionError}</span>
1162- {/if}
1163- </div>
1164- <div class="title-bar-controls">
1165- {#if !isMobile}
1166- <button
1167- aria-label="Minimize"
1168- onclick={(e) => {
1169- e.stopPropagation();
1170- const node = (e.currentTarget as HTMLElement).closest(
1171- '.window'
1172- ) as MinimizableNode | null;
1173- if (node?.toggleMinimize) node.toggleMinimize();
1174- }}
1175- ></button>
1176- <button aria-label="Maximize" class:maximized={isMaximized} onclick={handleMaximize}
1177- ></button>
1178- <button onclick={handleClose} aria-label="Close"></button>
1179- {:else}
1180- <button
1181- aria-label="Afficher/Masquer la liste des contacts"
1182- onclick={() => (showUserList = !showUserList)}
1183- class="contacts-btn">👥</button
1184- >
1185- <button
1186- aria-label="Minimize"
1187- onclick={(e) => {
1188- e.stopPropagation();
1189- const node = (e.currentTarget as HTMLElement).closest(
1190- '.window'
1191- ) as MinimizableNode | null;
1192- if (node?.toggleMinimize) node.toggleMinimize();
1193- }}
1194- ></button>
1195- <button onclick={handleClose} aria-label="Close"></button>
1196- {/if}
1197- </div>
1066+<div class="chat-room">
1067+ {#if mentionUnreadCount > 0 || connectionError}
1068+ <div class="room-status">
1069+ {#if mentionUnreadCount > 0}
1070+ <span class="room-mentions"
1071+ >({mentionUnreadCount} mention{mentionUnreadCount > 1 ? 's' : ''})</span
1072+ >
1073+ {/if}
1074+ {#if connectionError}
1075+ <span class="connection-error">⚠️ {connectionError}</span>
1076+ {/if}
11981077 </div>
1078+ {/if}
1079+
1080+ <div class="room-panel" bind:this={roomPanel}>
1081+ <div class="chat-container">
1082+ {#if rateLimitWarning}
1083+ <div class="rate-limit-warning" class:with-progress={cooldownEndTime}>
1084+ <span>{rateLimitWarning}</span>
1085+ {#if cooldownEndTime}
1086+ <small>Vous pourrez envoyer un autre message dans {cooldownProgress.toFixed(1)}s</small>
1087+ {/if}
1088+ </div>
1089+ {/if}
11991090
1200- {#if !isMinimized}
12011091 <div
1202- class="window-body"
1203- style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;"
1092+ class="sunken-panel chat-area"
1093+ style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
1094+ onscroll={(e) => handleScroll(e)}
1095+ bind:this={chatArea}
12041096 >
1205- {#if connectionError}
1206- <div class="error-banner">
1207- {connectionError}
1097+ {#if isInitialLoading}
1098+ <div class="initial-loading">
1099+ <LoadingDots text="Chargement des messages" />
1100+ </div>
1101+ {:else if isLoadingMore}
1102+ <div class="loading-messages">
1103+ <LoadingDots text="Chargement" />
12081104 </div>
12091105 {/if}
1210-
1211- <div
1212- class="chat-container"
1213- style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;"
1214- >
1215- {#if rateLimitWarning}
1216- <div class="rate-limit-warning" class:with-progress={cooldownEndTime}>
1217- <span>{rateLimitWarning}</span>
1218- {#if cooldownEndTime}
1219- <small
1220- >Vous pourrez envoyer un autre message dans {cooldownProgress.toFixed(1)}s</small
1221- >
1222- {/if}
1223- </div>
1224- {/if}
1225-
1106+ {#if showRegistrationPrompt}
1107+ <div class="registration-prompt">
1108+ <img src="/aim/xp-info-16.png" alt="" width="16" height="16" />
1109+ <span>Vous devez être inscrit pour lire l'historique complet de ce salon.</span>
1110+ <button type="button" onclick={openSignup}>S'inscrire</button>
1111+ </div>
1112+ {/if}
1113+ {#each visibleMessages as message (message.id)}
12261114 <div
1227- class="sunken-panel chat-area"
1228- style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
1229- onscroll={(e) => handleScroll(e)}
1230- bind:this={chatArea}
1115+ class="message {message.type} text"
1116+ class:mention-highlight={hasMentionHighlight[message.id]}
1117+ data-message-id={message.id}
1118+ data-mention={hasMentionHighlight[message.id]
1119+ ? `Mention de @${currentUser?.nickname ?? ''}`
1120+ : undefined}
1121+ aria-label={hasMentionHighlight[message.id]
1122+ ? `Message mentionnant ${currentUser?.nickname ?? ''}`
1123+ : undefined}
1124+ use:registerMentionNode
12311125 >
1232- {#if isInitialLoading}
1233- <div class="initial-loading">
1234- <LoadingDots text="Chargement des messages" />
1235- </div>
1236- {:else if isLoadingMore}
1237- <div class="loading-messages">
1238- <LoadingDots text="Chargement" />
1239- </div>
1240- {/if}
1241- {#if showRegistrationPrompt}
1242- <div class="registration-prompt">
1243- <p>
1244- 👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button>pour lire
1245- le reste du chat !
1246- </p>
1247- </div>
1248- {/if}
1249- {#each visibleMessages as message (message.id)}
1250- <div
1251- class="message {message.type} text"
1252- class:mention-highlight={hasMentionHighlight[message.id]}
1253- data-message-id={message.id}
1254- data-mention={hasMentionHighlight[message.id]
1255- ? `Mention de @${currentUser?.nickname ?? ''}`
1256- : undefined}
1257- aria-label={hasMentionHighlight[message.id]
1258- ? `Message mentionnant ${currentUser?.nickname ?? ''}`
1259- : undefined}
1260- use:registerMentionNode
1126+ {#if message.type === 'emote'}
1127+ <span class="emote-text">
1128+ <Tooltip
1129+ data={{
1130+ text: formatFrenchDateTime(new Date(message.timestamp)),
1131+ direction: 'left',
1132+ closeDelay: 1000,
1133+ touchBehavior: 'remove',
1134+ interactive: false
1135+ }}
1136+ >
1137+ <span class="nickname pointer-events-none">{message.user.nickname}</span>
1138+ </Tooltip>
1139+ {message.content}
1140+ </span>
1141+ {:else}
1142+ <Tooltip
1143+ data={{
1144+ text: formatFrenchDateTime(new Date(message.timestamp)),
1145+ direction: 'left',
1146+ closeDelay: 1000,
1147+ touchBehavior: 'remove',
1148+ interactive: false
1149+ }}
12611150 >
1262- {#if message.type === 'emote'}
1263- <span class="emote-text">
1264- <Tooltip
1265- data={{
1266- text: formatFrenchDateTime(new Date(message.timestamp)),
1267- direction: 'left',
1268- closeDelay: 1000,
1269- touchBehavior: 'remove',
1270- interactive: false
1271- }}
1272- >
1273- <span class="nickname pointer-events-none">{message.user.nickname}</span>
1274- </Tooltip>
1275- {message.content}
1276- </span>
1277- {:else}
1278- <Tooltip
1279- data={{
1280- text: formatFrenchDateTime(new Date(message.timestamp)),
1281- direction: 'left',
1282- closeDelay: 1000,
1283- touchBehavior: 'remove',
1284- interactive: false
1285- }}
1286- >
1287- <span class="nickname pointer-events-none">{message.user.nickname}:</span>
1288- </Tooltip>
1289- <span class="message-content">
1290- <FormattedMessage {message} allowFormatting={true} />
1291- </span>
1292- {/if}
1293- </div>
1294- {/each}
1151+ <span class="nickname pointer-events-none">{message.user.nickname}:</span>
1152+ </Tooltip>
1153+ <span class="message-content">
1154+ <FormattedMessage {message} allowFormatting={true} />
1155+ </span>
1156+ {/if}
12951157 </div>
1158+ {/each}
1159+ </div>
12961160
1297- <!-- Text Formatting Toolbar -->
1298- {#if currentUser}
1299- <div style="margin-bottom: 0.25rem;">
1300- <TextFormattingToolbar
1301- bind:style={currentTextStyle}
1302- compact={true}
1303- showFontSelector={true}
1304- />
1305- </div>
1306- {/if}
1161+ <!-- Text Formatting Toolbar -->
1162+ {#if currentUser}
1163+ <div style="margin-bottom: 0.25rem;">
1164+ <TextFormattingToolbar
1165+ bind:style={currentTextStyle}
1166+ compact={true}
1167+ showFontSelector={true}
1168+ onSmiley={insertSmiley}
1169+ />
1170+ </div>
1171+ {/if}
13071172
1308- <div class="field-row input-container" style="margin: 0; position: relative;">
1309- {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}
1310- <div
1311- class="gradient-input-wrapper"
1312- style="flex: 1; position: relative; background: white; overflow: hidden;"
1313- >
1314- <!-- Gradient text overlay that syncs with input scroll -->
1315- <span
1316- class="gradient-text-overlay retro-font-{currentTextStyle.fontFamily}"
1317- style="
1173+ <div class="field-row input-container" style="margin: 0; position: relative;">
1174+ {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}
1175+ <div
1176+ class="gradient-input-wrapper"
1177+ style="flex: 1; position: relative; background: white; overflow: hidden;"
1178+ >
1179+ <!-- Gradient text overlay that syncs with input scroll -->
1180+ <span
1181+ class="gradient-text-overlay retro-font-{currentTextStyle.fontFamily}"
1182+ style="
13181183 position: absolute;
13191184 left: 3px;
13201185 top: 50%;
@@ -1323,66 +1188,66 @@
13231188 white-space: nowrap;
13241189 font-size: {currentTextStyle.fontSize}px;
13251190 {currentTextStyle.bold
1326- ? currentTextStyle.fontFamily === 'tahoma'
1327- ? 'font-weight: 200;'
1328- : 'font-weight: 700;'
1329- : ''}
1191+ ? currentTextStyle.fontFamily === 'tahoma'
1192+ ? 'font-weight: 200;'
1193+ : 'font-weight: 700;'
1194+ : ''}
13301195 {currentTextStyle.italic ? 'font-style: italic;' : ''}
13311196 {currentTextStyle.underline || currentTextStyle.strikethrough
1332- ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1333- : ''}
1197+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1198+ : ''}
13341199 background: linear-gradient(to right, {currentTextStyle.gradient.join(', ')});
13351200 -webkit-background-clip: text;
13361201 -webkit-text-fill-color: transparent;
13371202 background-clip: text;
13381203 "
1339- aria-hidden="true"
1340- >
1341- <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1342- {@html formattedInputMessage}
1343- </span>
1344- <input
1345- type="text"
1346- bind:value={currentMessage}
1347- maxlength={MAX_MESSAGE_LENGTH}
1348- class="styled-input retro-font-{currentTextStyle.fontFamily}"
1349- style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
1350- ? currentTextStyle.fontFamily === 'tahoma'
1351- ? 'font-weight: 200;'
1352- : 'font-weight: 700;'
1353- : ''} {currentTextStyle.italic
1354- ? 'font-style: italic;'
1355- : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1356- ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1357- : ''}"
1358- onkeydown={handleInputKeydown}
1359- onkeyup={handleInputKeyup}
1360- oninput={handleInputChange}
1361- onscroll={handleInputScroll}
1362- onfocus={handleInputFocus}
1363- onclick={handleInputClick}
1364- bind:this={messageInput}
1365- placeholder={!currentUser
1366- ? 'Inscris-toi pour participer.'
1367- : cooldownEndTime
1368- ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1369- : 'Écrivez un message...'}
1370- disabled={!currentUser || Boolean(cooldownEndTime)}
1371- role="combobox"
1372- aria-autocomplete="list"
1373- aria-controls={mentionOpen ? mentionListBoxId : undefined}
1374- aria-expanded={mentionOpen}
1375- />
1376- </div>
1377- {:else}
1378- <div
1379- class="mention-input-wrapper"
1380- style="flex: 1; position: relative; background: white; overflow: hidden;"
1381- >
1382- <!-- Text overlay with mention highlighting -->
1383- <span
1384- class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}"
1385- style="
1204+ aria-hidden="true"
1205+ >
1206+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1207+ {@html formattedInputMessage}
1208+ </span>
1209+ <input
1210+ type="text"
1211+ bind:value={currentMessage}
1212+ maxlength={MAX_MESSAGE_LENGTH}
1213+ class="styled-input retro-font-{currentTextStyle.fontFamily}"
1214+ style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
1215+ ? currentTextStyle.fontFamily === 'tahoma'
1216+ ? 'font-weight: 200;'
1217+ : 'font-weight: 700;'
1218+ : ''} {currentTextStyle.italic
1219+ ? 'font-style: italic;'
1220+ : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1221+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1222+ : ''}"
1223+ onkeydown={handleInputKeydown}
1224+ onkeyup={handleInputKeyup}
1225+ oninput={handleInputChange}
1226+ onscroll={handleInputScroll}
1227+ onfocus={handleInputFocus}
1228+ onclick={handleInputClick}
1229+ bind:this={messageInput}
1230+ placeholder={!currentUser
1231+ ? 'Inscris-toi pour participer.'
1232+ : cooldownEndTime
1233+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1234+ : 'Écrivez un message...'}
1235+ disabled={!currentUser || Boolean(cooldownEndTime)}
1236+ role="combobox"
1237+ aria-autocomplete="list"
1238+ aria-controls={mentionOpen ? mentionListBoxId : undefined}
1239+ aria-expanded={mentionOpen}
1240+ />
1241+ </div>
1242+ {:else}
1243+ <div
1244+ class="mention-input-wrapper"
1245+ style="flex: 1; position: relative; background: white; overflow: hidden;"
1246+ >
1247+ <!-- Text overlay with mention highlighting -->
1248+ <span
1249+ class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}"
1250+ style="
13861251 position: absolute;
13871252 left: 3px;
13881253 top: 50%;
@@ -1392,134 +1257,222 @@
13921257 font-size: {currentTextStyle.fontSize}px;
13931258 color: {currentTextStyle.color || '#000000'};
13941259 {currentTextStyle.bold
1395- ? currentTextStyle.fontFamily === 'tahoma'
1396- ? 'font-weight: 200;'
1397- : 'font-weight: 700;'
1398- : ''}
1260+ ? currentTextStyle.fontFamily === 'tahoma'
1261+ ? 'font-weight: 200;'
1262+ : 'font-weight: 700;'
1263+ : ''}
13991264 {currentTextStyle.italic ? 'font-style: italic;' : ''}
14001265 {currentTextStyle.underline || currentTextStyle.strikethrough
1401- ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1402- : ''}
1266+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1267+ : ''}
14031268 "
1404- aria-hidden="true"
1405- >
1406- <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1407- {@html formattedInputMessage}
1408- </span>
1409- <input
1410- type="text"
1411- bind:value={currentMessage}
1412- maxlength={MAX_MESSAGE_LENGTH}
1413- class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}"
1414- style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
1415- ? currentTextStyle.fontFamily === 'tahoma'
1416- ? 'font-weight: 200;'
1417- : 'font-weight: 700;'
1418- : ''} {currentTextStyle.italic
1419- ? 'font-style: italic;'
1420- : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1421- ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1422- : ''} caret-color: {currentTextStyle.color || 'black'};"
1423- onkeydown={handleInputKeydown}
1424- onkeyup={handleInputKeyup}
1425- oninput={handleInputChange}
1426- onscroll={handleInputScroll}
1427- onfocus={handleInputFocus}
1428- onclick={handleInputClick}
1429- bind:this={messageInput}
1430- placeholder={!currentUser
1431- ? 'Inscris-toi pour participer.'
1432- : cooldownEndTime
1433- ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1434- : 'Écrivez un message...'}
1435- disabled={!currentUser || Boolean(cooldownEndTime)}
1436- role="combobox"
1437- aria-autocomplete="list"
1438- aria-controls={mentionOpen ? mentionListBoxId : undefined}
1439- aria-expanded={mentionOpen}
1440- />
1441- </div>
1442- {/if}
1443- <LoadingButton
1444- onclick={handleSubmit}
1269+ aria-hidden="true"
1270+ >
1271+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1272+ {@html formattedInputMessage}
1273+ </span>
1274+ <input
1275+ type="text"
1276+ bind:value={currentMessage}
1277+ maxlength={MAX_MESSAGE_LENGTH}
1278+ class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}"
1279+ style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
1280+ ? currentTextStyle.fontFamily === 'tahoma'
1281+ ? 'font-weight: 200;'
1282+ : 'font-weight: 700;'
1283+ : ''} {currentTextStyle.italic
1284+ ? 'font-style: italic;'
1285+ : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1286+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1287+ : ''} caret-color: {currentTextStyle.color || 'black'};"
1288+ onkeydown={handleInputKeydown}
1289+ onkeyup={handleInputKeyup}
1290+ oninput={handleInputChange}
1291+ onscroll={handleInputScroll}
1292+ onfocus={handleInputFocus}
1293+ onclick={handleInputClick}
1294+ bind:this={messageInput}
1295+ placeholder={!currentUser
1296+ ? 'Inscris-toi pour participer.'
1297+ : cooldownEndTime
1298+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1299+ : 'Écrivez un message...'}
14451300 disabled={!currentUser || Boolean(cooldownEndTime)}
1446- loading={isSendingMessage}
1447- text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'}
1301+ role="combobox"
1302+ aria-autocomplete="list"
1303+ aria-controls={mentionOpen ? mentionListBoxId : undefined}
1304+ aria-expanded={mentionOpen}
14481305 />
1449- {#if showCharCounter}
1450- <span class="char-counter {charCounterClass}">
1451- {currentMessage.length}/{MAX_MESSAGE_LENGTH}
1452- </span>
1453- {/if}
14541306 </div>
1455- {#if mentionOpen}
1456- <MentionPicker
1457- suggestions={mentionSuggestions}
1458- selectedIndex={mentionSelectedIndex}
1459- activeId={mentionActiveId}
1460- listBoxId={mentionListBoxId}
1461- listStyle={mentionListStyle}
1462- isPortal={!mentionListInside}
1463- onSelect={applyMentionSelection}
1464- />
1465- {/if}
1307+ {/if}
1308+ <LoadingButton
1309+ onclick={handleSubmit}
1310+ disabled={!currentUser || Boolean(cooldownEndTime)}
1311+ loading={isSendingMessage}
1312+ text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'}
1313+ />
1314+ {#if showCharCounter}
1315+ <span class="char-counter {charCounterClass}">
1316+ {currentMessage.length}/{MAX_MESSAGE_LENGTH}
1317+ </span>
1318+ {/if}
1319+ </div>
1320+ {#if mentionOpen}
1321+ <MentionPicker
1322+ suggestions={mentionSuggestions}
1323+ selectedIndex={mentionSelectedIndex}
1324+ activeId={mentionActiveId}
1325+ listBoxId={mentionListBoxId}
1326+ listStyle={mentionListStyle}
1327+ isPortal={!mentionListInside}
1328+ onSelect={applyMentionSelection}
1329+ />
1330+ {/if}
1331+
1332+ {#if cooldownEndTime}
1333+ <div
1334+ class="cooldown-progress"
1335+ style="width: {100 - ((cooldownProgress * 100) / (cooldownEndTime - Date.now())) * 1000}%"
1336+ ></div>
1337+ {/if}
1338+ </div>
14661339
1467- {#if cooldownEndTime}
1340+ <!-- People here (AIM chat room) -->
1341+ <div class="people-panel" class:mobile={isMobile} class:hidden={isMobile && !showUserList}>
1342+ <p class="people-count">
1343+ {usersOnline.length}
1344+ {usersOnline.length === 1 ? 'personne ici' : 'personnes ici'}
1345+ </p>
1346+ <div class="sunken-panel users-list" role="listbox" aria-label="Personnes dans le salon">
1347+ {#each usersOnline as user (user.id)}
1348+ <div
1349+ class="user {user.status}"
1350+ class:selected={selectedUserId === user.id}
1351+ class:me={currentUser?.id === user.id}
1352+ role="option"
1353+ aria-selected={selectedUserId === user.id}
1354+ tabindex="0"
1355+ title={statusLabel(user)}
1356+ onclick={(e) => {
1357+ e.stopPropagation();
1358+ selectedUserId = user.id;
1359+ closeUserMenu();
1360+ }}
1361+ ondblclick={() => openImWith(user)}
1362+ oncontextmenu={(e) => openUserMenu(e, user)}
1363+ onkeydown={(e) => {
1364+ if (e.key === 'Enter') openImWith(user);
1365+ }}
1366+ >
1367+ <span class="nickname">{user.nickname}</span>
1368+ </div>
1369+ {/each}
1370+ {#if usersOffline.length > 0}
1371+ <div class="offline-separator" role="presentation">Hors ligne</div>
1372+ {#each usersOffline as user (user.id)}
14681373 <div
1469- class="cooldown-progress"
1470- style="width: {100 -
1471- ((cooldownProgress * 100) / (cooldownEndTime - Date.now())) * 1000}%"
1472- ></div>
1473- {/if}
1474- </div>
1475-
1476- <!-- Online users list -->
1477- <div
1478- class="sunken-panel users-list"
1479- class:mobile={isMobile}
1480- class:hidden={isMobile && !showUserList}
1481- style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"
1374+ class="user offline"
1375+ class:selected={selectedUserId === user.id}
1376+ role="option"
1377+ aria-selected={selectedUserId === user.id}
1378+ tabindex="0"
1379+ title={statusLabel(user)}
1380+ onclick={(e) => {
1381+ e.stopPropagation();
1382+ selectedUserId = user.id;
1383+ closeUserMenu();
1384+ }}
1385+ ondblclick={() => openImWith(user)}
1386+ oncontextmenu={(e) => openUserMenu(e, user)}
1387+ onkeydown={(e) => {
1388+ if (e.key === 'Enter') openImWith(user);
1389+ }}
1390+ >
1391+ <span class="nickname">{user.nickname}</span>
1392+ </div>
1393+ {/each}
1394+ {/if}
1395+ </div>
1396+ <div class="people-actions">
1397+ <button
1398+ type="button"
1399+ disabled={!selectedUser || selectedUser.id === currentUser?.id}
1400+ onclick={() => selectedUser && openImWith(selectedUser)}>IM</button
14821401 >
1483- <!-- Online users section -->
1484- {#if usersOnline.length > 0}
1485- <p class="section-header">En ligne ({usersOnline.length})</p>
1486- {#each usersOnline as user (user.id)}
1487- <div class="user">{user.nickname}</div>
1488- {/each}
1489- {/if}
1402+ <button
1403+ type="button"
1404+ disabled={!selectedUser}
1405+ onclick={() =>
1406+ selectedUser &&
1407+ desktop.openProfile({
1408+ userId: selectedUser.id as Id<'users'>,
1409+ nickname: selectedUser.nickname
1410+ })}>Infos</button
1411+ >
1412+ <button
1413+ type="button"
1414+ disabled={!selectedUser || selectedUser.id === currentUser?.id}
1415+ onclick={() =>
1416+ selectedUser && void aim.addBuddy(selectedUser.id as Id<'users'>).catch(console.error)}
1417+ >Ajouter</button
1418+ >
1419+ </div>
1420+ </div>
14901421
1491- <!-- Offline users section -->
1492- {#if usersOffline.length > 0}
1493- {#if usersOnline.length > 0}
1494- <div class="section-separator"></div>
1495- {/if}
1496- <p class="section-header offline-header">Hors ligne ({usersOffline.length})</p>
1497- {#each usersOffline as user (user.id)}
1498- <div class="user offline">
1499- <Tooltip
1500- data={{
1501- text: 'Dernière connexion: ' + formatFrenchRelativeTimeSafe(user.lastSeen),
1502- direction: 'bottom',
1503- closeDelay: 1000,
1504- touchBehavior: 'remove'
1505- }}
1506- >
1507- <span class="nickname">{user.nickname}</span>
1508- </Tooltip>
1509- </div>
1510- {/each}
1511- {/if}
1512- </div>
1422+ {#if userMenu}
1423+ <div
1424+ class="user-menu"
1425+ style="left: {userMenu.x}px; top: {userMenu.y}px;"
1426+ role="menu"
1427+ tabindex="-1"
1428+ onclick={(e) => e.stopPropagation()}
1429+ onkeydown={(e) => {
1430+ if (e.key === 'Escape') closeUserMenu();
1431+ }}
1432+ >
1433+ <div class="user-menu-title">{userMenu.user.nickname}</div>
1434+ {#if currentUser && userMenu.user.id !== currentUser.id}
1435+ <button
1436+ type="button"
1437+ role="menuitem"
1438+ onclick={() => {
1439+ const u = userMenu?.user;
1440+ closeUserMenu();
1441+ if (u) void openImWith(u);
1442+ }}>Envoyer un message instantané</button
1443+ >
1444+ <button
1445+ type="button"
1446+ role="menuitem"
1447+ onclick={() => {
1448+ const u = userMenu?.user;
1449+ closeUserMenu();
1450+ if (u) void aim.addBuddy(u.id as Id<'users'>).catch(console.error);
1451+ }}>Ajouter à ma liste de contacts</button
1452+ >
1453+ {/if}
1454+ <button
1455+ type="button"
1456+ role="menuitem"
1457+ onclick={() => {
1458+ const u = userMenu?.user;
1459+ closeUserMenu();
1460+ if (u) desktop.openProfile({ userId: u.id as Id<'users'>, nickname: u.nickname });
1461+ }}>Infos sur {userMenu.user.nickname}</button
1462+ >
15131463 </div>
15141464 {/if}
15151465 </div>
1516-{/if}
1517-
1518-{#if showAuth}
1519- <AimLogin bind:showAuth activeTab={'signup' as const} onLoginSuccess={handleLoginSuccess} />
1520-{/if}
1466+</div>
15211467
15221468 <style>
1469+ .chat-room {
1470+ flex: 1 1 auto;
1471+ min-height: 0;
1472+ display: flex;
1473+ flex-direction: column;
1474+ }
1475+
15231476 .chat-area,
15241477 .users-list {
15251478 font-size: 1rem;
@@ -1531,34 +1484,159 @@
15311484 font-family: 'Pixelated MS Sans Serif', Arial, Verdana, Tahoma, sans-serif;
15321485 }
15331486
1534- .title-bar {
1535- height: 2rem;
1487+ /* ===== Layout inside the XP window ===== */
1488+ .room-status {
1489+ display: flex;
1490+ justify-content: flex-end;
1491+ gap: 6px;
1492+ font-size: 11px;
1493+ margin-bottom: 3px;
1494+ }
1495+
1496+ .room-mentions {
1497+ margin-left: auto;
1498+ font-weight: bold;
1499+ color: #c00000;
1500+ white-space: nowrap;
1501+ }
1502+
1503+ .room-panel {
15361504 position: relative;
1505+ flex: 1 1 auto;
1506+ min-height: 0;
1507+ display: flex;
1508+ gap: 6px;
1509+ }
1510+
1511+ .chat-container {
1512+ flex: 1 1 auto;
1513+ min-width: 0;
1514+ display: flex;
1515+ flex-direction: column;
1516+ }
1517+
1518+ .people-panel {
1519+ width: 9.5rem;
1520+ flex: 0 0 auto;
1521+ display: flex;
1522+ flex-direction: column;
1523+ min-height: 0;
1524+ }
1525+
1526+ .people-count {
1527+ margin: 0 0 3px 0;
1528+ font-weight: bold;
1529+ font-size: 0.8rem;
1530+ white-space: nowrap;
1531+ }
1532+
1533+ .users-list {
1534+ flex: 1 1 auto;
1535+ min-height: 0;
1536+ padding: 2px;
1537+ overflow-y: auto;
1538+ font-size: 0.85rem;
1539+ }
1540+
1541+ .people-actions {
1542+ display: flex;
1543+ gap: 3px;
1544+ margin-top: 4px;
1545+ }
1546+
1547+ .people-actions button {
1548+ flex: 1 1 0;
1549+ min-width: 0;
1550+ padding: 0 4px;
1551+ font-size: 0.75rem;
1552+ }
1553+
1554+ .user {
1555+ display: flex;
1556+ align-items: center;
1557+ padding: 1px 3px;
15371558 cursor: default;
1559+ border: 1px dotted transparent;
1560+ user-select: none;
1561+ overflow: hidden;
1562+ }
1563+
1564+ .offline-separator {
1565+ margin: 4px 2px 2px;
1566+ padding-top: 3px;
1567+ border-top: 1px solid #d4d0c8;
1568+ color: #808080;
1569+ font-size: 0.7rem;
15381570 user-select: none;
1571+ }
1572+
1573+ .user.offline .nickname {
1574+ color: #808080;
1575+ font-style: italic;
1576+ }
1577+
1578+ .user .nickname {
1579+ overflow: hidden;
1580+ text-overflow: ellipsis;
1581+ white-space: nowrap;
1582+ }
1583+
1584+ .user.selected {
1585+ background: #316ac5;
1586+ color: #fff;
1587+ border-color: #fff;
1588+ }
1589+
1590+ .user.me .nickname {
1591+ font-weight: bold;
1592+ }
1593+
1594+ .user.away .nickname,
1595+ .user.idle .nickname {
1596+ color: #777;
1597+ font-style: italic;
1598+ }
1599+
1600+ .user.selected .nickname {
1601+ color: #fff;
1602+ }
1603+
1604+ .user-menu {
1605+ position: absolute;
1606+ z-index: 50;
1607+ min-width: 200px;
1608+ background: #fff;
1609+ border: 1px solid #aca899;
1610+ box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
1611+ padding: 2px;
1612+ font-family: Tahoma, 'Pixelated MS Sans Serif', sans-serif;
1613+ font-size: 11px;
15391614 display: flex;
1540- align-items: center;
1541- padding: 0 0.5rem;
1615+ flex-direction: column;
15421616 }
15431617
1544- .title-bar:not(:has(button:hover)) {
1545- cursor: move;
1618+ .user-menu-title {
1619+ font-weight: bold;
1620+ padding: 3px 20px;
1621+ border-bottom: 1px solid #aca899;
1622+ margin-bottom: 2px;
1623+ color: #444;
15461624 }
15471625
1548- .chat-window {
1549- position: fixed;
1550- box-sizing: border-box;
1551- transition:
1552- width 0.3s ease,
1553- height 0.3s ease,
1554- left 0.3s ease,
1555- top 0.3s ease;
1556- will-change: transform;
1626+ .user-menu button {
1627+ text-align: left;
1628+ background: transparent;
1629+ border: none;
1630+ box-shadow: none;
1631+ border-radius: 0;
1632+ padding: 3px 20px;
1633+ min-width: 0;
1634+ font-size: 11px;
15571635 }
15581636
1559- .chat-window.dragging {
1560- transition: none !important;
1561- user-select: none;
1637+ .user-menu button:hover {
1638+ background: #316ac5;
1639+ color: #fff;
15621640 }
15631641
15641642 .chat-area {
@@ -1647,42 +1725,6 @@
16471725 -webkit-text-fill-color: transparent !important;
16481726 }
16491727
1650- .section-header {
1651- margin: 0 0 0.25rem 0;
1652- font-weight: bold;
1653- font-size: 0.75rem;
1654- text-transform: uppercase;
1655- letter-spacing: 0.05em;
1656- color: var(--color-muted-foreground);
1657- }
1658-
1659- .offline-header {
1660- opacity: 0.7;
1661- }
1662-
1663- .section-separator {
1664- border-top: 1px solid var(--color-border);
1665- margin: 0.5rem 0;
1666- }
1667-
1668- .user {
1669- margin-bottom: 0.125rem;
1670- padding-left: 0.25rem;
1671- line-height: 1.4;
1672- font-size: 0.9rem;
1673- font-weight: normal;
1674- color: var(--color-foreground);
1675- }
1676-
1677- .user.offline {
1678- color: var(--color-muted-foreground);
1679- font-style: italic;
1680- }
1681-
1682- .user.offline .nickname {
1683- cursor: help;
1684- }
1685-
16861728 .sunken-panel {
16871729 background: white;
16881730 border: 0.125rem inset #dfdfdf;
@@ -1697,8 +1739,14 @@
16971739 font-style: inherit;
16981740 }
16991741
1700- button {
1701- font-size: 1rem;
1742+ .input-container :global(> button) {
1743+ height: 21px;
1744+ min-height: 21px;
1745+ min-width: 0;
1746+ padding: 0 10px;
1747+ margin-left: 4px;
1748+ font-size: 11px;
1749+ white-space: nowrap;
17021750 }
17031751
17041752 .chat-container {
@@ -1728,90 +1776,14 @@
17281776 display: none !important;
17291777 }
17301778
1731- :global(.resize-handle) {
1732- position: absolute;
1733- bottom: 0;
1734- right: 0;
1735- width: 15px !important;
1736- height: 15px !important;
1737- 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='%23000'/%3E%3C/svg%3E");
1738- background-position: bottom right;
1739- background-repeat: no-repeat;
1740- cursor: se-resize !important;
1741- }
1742-
17431779 @media (max-width: 768px) {
1744- .window {
1745- position: fixed !important;
1746- top: 0 !important;
1747- left: 0 !important;
1748- right: 0 !important;
1749- bottom: 0 !important;
1750- width: 100% !important;
1751- height: 100% !important;
1752- transition: transform 0.3s ease !important;
1753- margin: 0 !important;
1754- border-radius: 0 !important;
1755- }
1756-
1757- .window.minimized {
1758- transform: translateY(calc(100% - 32px)) !important;
1759- height: 100% !important;
1760- }
1761-
1762- .window.minimized .title-bar {
1763- height: 32px;
1764- }
1765-
1766- .title-bar-controls button {
1767- width: 24px;
1768- height: 24px;
1769- padding: 0;
1770- margin: 0 2px;
1771- background-color: transparent;
1772- border: 1px solid transparent;
1773- position: relative;
1774- }
1775-
1776- .title-bar-controls button.contacts-btn {
1777- width: auto;
1778- padding: 0 0.5rem;
1779- font-size: 1.25rem;
1780- }
1781-
1782- .window.minimized .title-bar-controls button {
1783- opacity: 0.8;
1784- }
1785-
1786- .window.minimized .title-bar-controls button.contacts-btn {
1787- opacity: 0.8;
1788- }
1789-
1790- .window-body {
1780+ .room-panel {
17911781 flex-direction: column;
17921782 }
17931783
1794- .chat-container {
1795- margin-right: 0 !important;
1796- margin-bottom: 0 !important;
1797- height: 100%;
1798- }
1799-
1800- .users-list.mobile {
1801- position: fixed;
1802- bottom: 0;
1803- left: 0;
1804- right: 0;
1805- height: auto;
1784+ .people-panel.mobile {
1785+ width: 100%;
18061786 max-height: 30vh;
1807- z-index: 1000;
1808- border-top: 0.125rem solid #dfdfdf;
1809- background: white;
1810- }
1811-
1812- .input-container {
1813- padding: 0.5rem;
1814- border-top: 0.125rem solid #dfdfdf;
18151787 }
18161788 }
18171789
@@ -1947,59 +1919,31 @@
19471919 }
19481920
19491921 .registration-prompt {
1950- background: #fff3e0;
1951- color: #e65100;
1952- padding: 1rem;
1953- margin-bottom: 1rem;
1954- border-radius: 4px;
1955- text-align: center;
1956- font-size: 0.95rem;
1957- border: 1px solid #ffe0b2;
1958- animation: fadeIn 0.3s ease-out;
1959- }
1960-
1961- .registration-prompt p {
1962- margin: 0;
1963- }
1964-
1965- @keyframes fadeIn {
1966- from {
1967- opacity: 0;
1968- transform: translateY(-10px);
1969- }
1970- to {
1971- opacity: 1;
1972- transform: translateY(0);
1973- }
1922+ display: flex;
1923+ align-items: center;
1924+ gap: 8px;
1925+ background: #ece9d8;
1926+ color: #000;
1927+ padding: 6px 8px;
1928+ margin-bottom: 6px;
1929+ border: 1px solid #aca899;
1930+ font-size: 0.85rem;
19741931 }
19751932
1976- .registration-prompt .link-button {
1977- color: #e65100;
1978- text-decoration: underline;
1979- cursor: pointer;
1980- background: none;
1981- border: none;
1982- padding: 0;
1983- font: inherit;
1933+ .registration-prompt span {
1934+ flex: 1;
19841935 }
19851936
1986- .registration-prompt .link-button:hover {
1987- color: #ef6c00;
1988- text-decoration: none;
1937+ .registration-prompt button {
1938+ min-width: 0;
1939+ padding: 0 10px;
1940+ white-space: nowrap;
19891941 }
19901942
19911943 .chat-window.minimized {
19921944 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
19931945 }
19941946
1995- .minimized .title-bar {
1996- cursor: pointer;
1997- }
1998-
1999- .minimized .window-body {
2000- display: none;
2001- }
2002-
20031947 .minimized :global(.resize-handle) {
20041948 display: none;
20051949 }
M src/lib/components/color-picker-256.svelte +30 -9
@@ -16,6 +16,26 @@
1616
1717 // State
1818 let showPicker = $state(false);
19+ // Fixed viewport position so the dropdown escapes the window's overflow clipping
20+ let pickerPos = $state({ left: 0, top: 0 });
21+ const PICKER_WIDTH = 420;
22+ const PICKER_MAX_HEIGHT = 380;
23+
24+ function placePicker(button: HTMLElement) {
25+ const rect = button.getBoundingClientRect();
26+ const margin = 4;
27+ const left = Math.min(
28+ Math.max(margin, rect.right - PICKER_WIDTH),
29+ window.innerWidth - PICKER_WIDTH - margin
30+ );
31+ // Prefer opening upward (the toolbar sits above the input at the bottom)
32+ const above = rect.top - PICKER_MAX_HEIGHT - 2;
33+ const top =
34+ above >= margin
35+ ? above
36+ : Math.min(rect.bottom + 2, window.innerHeight - PICKER_MAX_HEIGHT - margin);
37+ pickerPos = { left: Math.max(margin, left), top: Math.max(margin, top) };
38+ }
1939 let gradientColors = $state<string[]>([selectedColor || '#000000']);
2040 let activeGradientSlot = $state(0);
2141
@@ -123,6 +143,7 @@
123143 <button
124144 onclick={(e) => {
125145 e.stopPropagation();
146+ if (!showPicker) placePicker(e.currentTarget as HTMLElement);
126147 showPicker = !showPicker;
127148 }}
128149 title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'}
@@ -137,7 +158,11 @@
137158 <!-- Color picker dropdown -->
138159 {#if showPicker}
139160 <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
140- <div class="picker-dropdown sunken-panel" onclick={(e) => e.stopPropagation()}>
161+ <div
162+ class="picker-dropdown sunken-panel"
163+ style="left: {pickerPos.left}px; top: {pickerPos.top}px;"
164+ onclick={(e) => e.stopPropagation()}
165+ >
141166 <!-- Gradient controls -->
142167 <div class="gradient-controls">
143168 <div class="gradient-slots">
@@ -233,12 +258,9 @@
233258 }
234259
235260 .picker-dropdown {
236- position: absolute;
237- top: 100%;
238- right: 0; /* Changed from left: 0 to prevent cutoff */
239- z-index: 1000;
261+ position: fixed;
262+ z-index: 6000; /* above windows and the taskbar */
240263 padding: 4px;
241- margin-top: 2px;
242264 background: #ece9d8; /* Authentic XP dialog background */
243265 width: 420px;
244266 max-height: 380px;
@@ -251,9 +273,8 @@
251273 /* Responsive positioning for mobile/small screens */
252274 @media (max-width: 768px) {
253275 .picker-dropdown {
254- position: fixed;
255- top: 50%;
256- left: 50%;
276+ top: 50% !important;
277+ left: 50% !important;
257278 transform: translate(-50%, -50%);
258279 right: auto;
259280 width: 90vw;
M src/lib/components/desktop-icons.svelte +101 -52
@@ -1,5 +1,54 @@
11 <script lang="ts">
2- let { showChatRoom = $bindable(), showAuth = $bindable() } = $props();
2+ import { desktop } from '$lib/states/desktop.svelte';
3+ import { chatState } from '$lib/states/chat.svelte';
4+
5+ const currentUser = $derived(chatState.getCurrentUser());
6+
7+ interface DesktopIcon {
8+ id: string;
9+ label: string;
10+ icon: string;
11+ action: () => void;
12+ hidden?: boolean;
13+ }
14+
15+ const icons = $derived<DesktopIcon[]>([
16+ {
17+ id: 'aim',
18+ label: 'PDR AIM',
19+ icon: '/desktop/pdraim-icon.png',
20+ // Signed out: the sign-on window (which opens the buddy list and unlocks the
21+ // chat room once logged in). Signed in: the buddy list.
22+ action: () => (currentUser ? desktop.openBuddyList() : desktop.openLogin('signin'))
23+ },
24+ {
25+ id: 'general',
26+ label: 'Salon General',
27+ icon: '/aim/chat-room-48.png',
28+ action: () => desktop.openChatRoom(null, 'General')
29+ },
30+ {
31+ id: 'away',
32+ label: 'Message d’absence',
33+ icon: '/aim/xp-note-48.png',
34+ action: () => desktop.openAway(),
35+ hidden: !currentUser
36+ },
37+ {
38+ id: 'prefs',
39+ label: 'Préférences',
40+ icon: '/aim/xp-control-panel-48.png',
41+ action: () => desktop.openPreferences()
42+ },
43+ {
44+ id: 'about',
45+ label: 'Aide',
46+ icon: '/aim/xp-help-48.png',
47+ action: () => desktop.openAbout()
48+ }
49+ ]);
50+
51+ let selected = $state<string | null>(null);
352
453 function handleKeydown(event: KeyboardEvent, action: () => void) {
554 if (event.key === 'Enter' || event.key === ' ') {
@@ -7,89 +56,89 @@
756 action();
857 }
958 }
10-
11- function handleChatRoomClick() {
12- showChatRoom = true;
13- }
14-
15- function handleAuthClick() {
16- showAuth = true;
17- }
1859 </script>
1960
2061 <div class="desktop-icons">
21- <div
22- class="desktop-icon"
23- ondblclick={handleChatRoomClick}
24- onkeydown={(e) => handleKeydown(e, handleChatRoomClick)}
25- role="button"
26- tabindex="0"
27- >
28- <img src="/desktop/aim-desktop-icon.png" alt="Salon de discussion" />
29- <span class="icon-label">Pdr Aim</span>
30- </div>
31-
32- <div
33- class="desktop-icon"
34- ondblclick={handleAuthClick}
35- onkeydown={(e) => handleKeydown(e, handleAuthClick)}
36- role="button"
37- tabindex="0"
38- >
39- <img src="/desktop/application-x-shellscript.png" alt="Connexion" />
40- <span class="icon-label">Connexion</span>
41- </div>
62+ {#each icons.filter((i) => !i.hidden) as icon (icon.id)}
63+ <div
64+ class="desktop-icon"
65+ class:selected={selected === icon.id}
66+ onclick={(e) => {
67+ e.stopPropagation();
68+ selected = icon.id;
69+ }}
70+ ondblclick={icon.action}
71+ onkeydown={(e) => handleKeydown(e, icon.action)}
72+ onfocus={() => (selected = icon.id)}
73+ role="button"
74+ tabindex="0"
75+ >
76+ <img src={icon.icon} alt="" />
77+ <span class="icon-label">{icon.label}</span>
78+ </div>
79+ {/each}
4280 </div>
4381
4482 <style>
4583 .desktop-icons {
4684 position: fixed;
47- top: 20px;
48- left: 20px;
85+ top: 16px;
86+ left: 12px;
4987 display: flex;
5088 flex-direction: column;
51- gap: 20px;
89+ gap: 10px;
90+ z-index: 1;
5291 }
5392
5493 .desktop-icon {
5594 display: flex;
5695 flex-direction: column;
5796 align-items: center;
58- cursor: pointer;
59- width: 75px;
97+ cursor: default;
98+ width: 80px;
6099 text-align: center;
61- padding: 8px;
100+ padding: 6px 4px;
62101 border: 1px dotted transparent;
63102 border-radius: 2px;
64- margin: 2px;
103+ outline: none;
65104 }
66105
67- .desktop-icon:hover,
68- .desktop-icon:focus {
69- background: rgba(49, 106, 197, 0.2);
70- border: 1px dotted rgba(255, 255, 255, 0.8);
71- outline: 1px solid rgba(49, 106, 197, 0.6);
72- outline-offset: -1px;
106+ .desktop-icon:hover {
107+ background: rgba(49, 106, 197, 0.15);
73108 }
74109
75- .desktop-icon:active {
76- background: rgba(49, 106, 197, 0.4);
110+ .desktop-icon.selected,
111+ .desktop-icon:focus-visible {
112+ background: rgba(49, 106, 197, 0.45);
113+ border-color: rgba(255, 255, 255, 0.8);
77114 }
78115
79116 .desktop-icon img {
80- width: 48px;
81- height: 48px;
82- margin-bottom: 5px;
83- pointer-events: none; /* Prevents image from interfering with hover */
117+ width: 40px;
118+ height: 40px;
119+ margin-bottom: 4px;
120+ pointer-events: none;
121+ image-rendering: pixelated;
122+ filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.6));
84123 }
85124
86125 .icon-label {
87126 color: white;
88- text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8);
89- font-size: 12px;
127+ text-shadow:
128+ 1px 1px 1px rgba(0, 0, 0, 0.9),
129+ 0 0 4px rgba(0, 0, 0, 0.6);
130+ font-family: Tahoma, 'Pixelated MS Sans Serif', sans-serif;
131+ font-size: 11px;
90132 user-select: none;
91- pointer-events: none; /* Prevents text from interfering with hover */
133+ pointer-events: none;
92134 max-width: 100%;
93135 overflow-wrap: break-word;
136+ line-height: 1.2;
137+ }
138+
139+ @media (max-width: 768px) {
140+ .desktop-icons {
141+ display: none;
142+ }
94143 }
95144 </style>
A src/lib/components/desktop/about-window.svelte +46 -0
@@ -0,0 +1,46 @@
1+<script lang="ts">
2+ import XpWindow from './xp-window.svelte';
3+ import { desktop } from '$lib/states/desktop.svelte';
4+</script>
5+
6+<XpWindow id="about" width={360} height={260} resizableWindow={false}>
7+ <div class="content">
8+ <img src="/aim/running-man-48.png" alt="" width="48" height="48" />
9+ <h2>PDR AIM</h2>
10+ <p>v0.3.0</p>
11+ <p>
12+ Un bac à sable associatif et sans but lucratif, pour retrouver l’époque des salons de
13+ discussion AOL/AIM.
14+ </p>
15+ <p>Un petit coin de nostalgie pour les anciens habitués.</p>
16+ <a href="https://pdraim.org" target="_blank" rel="noopener noreferrer">pdraim.org</a>
17+ <div class="actions"><button onclick={() => desktop.close('about')}>OK</button></div>
18+ </div>
19+</XpWindow>
20+
21+<style>
22+ .content {
23+ display: flex;
24+ flex-direction: column;
25+ align-items: center;
26+ text-align: center;
27+ gap: 4px;
28+ height: 100%;
29+ overflow: auto;
30+ font:
31+ 11px Tahoma,
32+ 'Pixelated MS Sans Serif',
33+ sans-serif;
34+ }
35+ h2,
36+ p {
37+ margin: 0;
38+ }
39+ h2 {
40+ font-size: 18px;
41+ }
42+ .actions {
43+ margin-top: auto;
44+ align-self: flex-end;
45+ }
46+</style>
A src/lib/components/desktop/away-window.svelte +146 -0
@@ -0,0 +1,146 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { api } from '../../../convex/_generated/api';
4+ import XpWindow from './xp-window.svelte';
5+ import { desktop } from '$lib/states/desktop.svelte';
6+ import { authQuery, useAimClient } from '$lib/aim/client';
7+
8+ const presets = [
9+ 'Je suis parti(e) manger',
10+ 'Au téléphone…',
11+ 'Parti(e) faire un tour, je reviens',
12+ "En cours, j'ai pas le droit d'être là 😅",
13+ 'Je dors, laisse un message'
14+ ];
15+ const me = authQuery(api.aim.getMe, () => ({}));
16+ const client = useAimClient();
17+ let text = $state('');
18+ let custom = $state('');
19+ let selected = $state('custom');
20+ let pending = $state(false);
21+ let error = $state('');
22+ let initialized = $state(false);
23+
24+ onMount(() => {
25+ custom = localStorage.getItem('pdraim-away-last') ?? '';
26+ });
27+ $effect(() => {
28+ const data = me?.data;
29+ if (!initialized && data) {
30+ const current = data.awayMessage ?? '';
31+ text = current || custom;
32+ selected = presets.includes(current) ? current : 'custom';
33+ initialized = true;
34+ }
35+ });
36+
37+ function selectPreset(event: Event) {
38+ selected = (event.currentTarget as HTMLSelectElement).value;
39+ text = selected === 'custom' ? custom : selected;
40+ }
41+ function editText() {
42+ selected = 'custom';
43+ custom = text;
44+ localStorage.setItem('pdraim-away-last', custom);
45+ }
46+ async function apply(value?: string) {
47+ pending = true;
48+ error = '';
49+ try {
50+ await client.setAwayMessage(value);
51+ if (value && selected === 'custom') localStorage.setItem('pdraim-away-last', value);
52+ desktop.close('away');
53+ } catch (err) {
54+ error = err instanceof Error ? err.message : 'Impossible de modifier le message.';
55+ } finally {
56+ pending = false;
57+ }
58+ }
59+</script>
60+
61+<XpWindow id="away" width={360} height={340} resizableWindow={false}>
62+ <div class="content">
63+ <label for="away-preset">Messages d’absence enregistrés</label>
64+ <!-- Inline list box (like AIM) so the choices always stay inside the window -->
65+ <select
66+ id="away-preset"
67+ class="preset-list"
68+ size={presets.length + 1}
69+ value={selected}
70+ onchange={selectPreset}
71+ >
72+ {#each presets as preset (preset)}<option value={preset}>{preset}</option>{/each}
73+ <option value="custom">Personnalisé…</option>
74+ </select>
75+ <label for="away-text">Votre message</label>
76+ <textarea id="away-text" maxlength="300" bind:value={text} oninput={editText}></textarea>
77+ <small>{text.length}/300</small>
78+ {#if error}<span role="alert">{error}</span>{/if}
79+ <div class="actions">
80+ <button disabled={pending || !text.trim()} onclick={() => apply(text)}
81+ >Je suis absent(e)</button
82+ >
83+ <button disabled={pending} onclick={() => apply(undefined)}>Je suis de retour</button>
84+ <button onclick={() => desktop.close('away')}>Annuler</button>
85+ </div>
86+ </div>
87+</XpWindow>
88+
89+<style>
90+ .content {
91+ display: flex;
92+ flex-direction: column;
93+ gap: 7px;
94+ height: 100%;
95+ font:
96+ 11px Tahoma,
97+ 'Pixelated MS Sans Serif',
98+ sans-serif;
99+ }
100+ select,
101+ textarea {
102+ width: 100%;
103+ box-sizing: border-box;
104+ font: inherit;
105+ }
106+ .preset-list {
107+ height: auto;
108+ padding: 1px;
109+ background-image: none;
110+ border: 1px solid #7f9db9;
111+ overflow: hidden;
112+ }
113+ .preset-list:focus {
114+ background-color: #fff;
115+ color: inherit;
116+ }
117+ .preset-list option {
118+ padding: 1px 3px;
119+ overflow: hidden;
120+ text-overflow: ellipsis;
121+ white-space: nowrap;
122+ }
123+ .preset-list option:checked {
124+ background: #316ac5;
125+ color: #fff;
126+ }
127+ textarea {
128+ flex: 1;
129+ min-height: 60px;
130+ resize: none;
131+ border: 1px solid #7f9db9;
132+ }
133+ small {
134+ align-self: flex-end;
135+ }
136+ .actions {
137+ display: flex;
138+ gap: 4px;
139+ flex-wrap: wrap;
140+ justify-content: flex-end;
141+ margin-top: auto;
142+ }
143+ [role='alert'] {
144+ color: #a00;
145+ }
146+</style>
A src/lib/components/desktop/buddy-list.svelte +721 -0
@@ -0,0 +1,721 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { SvelteSet, SvelteMap } from 'svelte/reactivity';
4+ import { useQuery } from 'convex-svelte';
5+ import { api } from '../../../convex/_generated/api';
6+ import type { Id } from '../../../convex/_generated/dataModel';
7+ import type { Buddy, AimStatus } from '$lib/types/aim';
8+ import { DEFAULT_BUDDY_GROUPS, STATUS_LABELS_FR } from '$lib/types/aim';
9+ import { playSound } from '$lib/aim/sounds.svelte';
10+ import { authQuery, useAimClient, convexAvailable } from '$lib/aim/client';
11+ import { chatState } from '$lib/states/chat.svelte';
12+ import { desktop } from '$lib/states/desktop.svelte';
13+ import XpWindow from './xp-window.svelte';
14+ import { browser } from '$app/environment';
15+
16+ // Dock top-right like the real AIM buddy list, with breathing room above and
17+ // above the taskbar (30px) below.
18+ const BUDDY_LIST_WIDTH = 230;
19+ const buddyListHeight = browser
20+ ? Math.max(360, Math.min(520, window.innerHeight - 30 - 2 * 40))
21+ : 520;
22+
23+ const aim = useAimClient();
24+ const buddyQuery = authQuery(api.aim.getMyBuddies, () => ({}));
25+ const directQuery = authQuery(api.aim.getMyDirectRooms, () => ({}));
26+ const meQuery = authQuery(api.aim.getMe, () => ({}));
27+ const roomsQuery = convexAvailable ? useQuery(api.aim.getGroupRooms, {}) : null;
28+ const usersQuery = convexAvailable ? useQuery(api.queries.getUsersPublic, {}) : null;
29+ const me = $derived(chatState.getCurrentUser());
30+ const buddies = $derived(buddyQuery?.data ?? []);
31+ const collapsed = new SvelteSet<string>();
32+ const flashing = new SvelteSet<string>();
33+ let selectedId = $state<Id<'users'> | null>(null);
34+ let editMode = $state(false);
35+ let panel = $state<'im' | 'add' | null>(null);
36+ let nickname = $state('');
37+ let addId = $state('');
38+ let addGroup = $state<string>('Buddies');
39+ let customGroup = $state('');
40+ let error = $state('');
41+ let dropdownMenu = $state<'aim' | 'contacts' | 'help' | null>(null);
42+ let context = $state<{ buddy: Buddy; x: number; y: number } | null>(null);
43+ let menuBar: HTMLDivElement;
44+ let contextElement = $state<HTMLDivElement>();
45+ let previous = new SvelteMap<string, AimStatus>();
46+ let initialized = false;
47+ const timers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
48+
49+ const sortedGroups = $derived.by(() => {
50+ const groups = new SvelteMap<string, Buddy[]>();
51+ for (const name of DEFAULT_BUDDY_GROUPS) groups.set(name, []);
52+ for (const buddy of buddies) {
53+ const name = buddy.group || 'Buddies';
54+ if (!groups.has(name)) groups.set(name, []);
55+ if (buddy.status !== 'offline') groups.get(name)!.push(buddy);
56+ }
57+ const rank: Record<AimStatus, number> = { online: 0, away: 1, idle: 2, busy: 3, offline: 4 };
58+ return [...groups.entries()]
59+ .sort(([a], [b]) => {
60+ const ai = DEFAULT_BUDDY_GROUPS.indexOf(a as (typeof DEFAULT_BUDDY_GROUPS)[number]);
61+ const bi = DEFAULT_BUDDY_GROUPS.indexOf(b as (typeof DEFAULT_BUDDY_GROUPS)[number]);
62+ return ai !== -1 && bi !== -1
63+ ? ai - bi
64+ : ai !== -1
65+ ? -1
66+ : bi !== -1
67+ ? 1
68+ : a.localeCompare(b);
69+ })
70+ .map(([name, members]) => ({
71+ name,
72+ members: members.sort(
73+ (a, b) => rank[a.status] - rank[b.status] || a.nickname.localeCompare(b.nickname)
74+ ),
75+ total: buddies.filter((b) => (b.group || 'Buddies') === name).length
76+ }));
77+ });
78+ const offline = $derived(
79+ buddies
80+ .filter((b) => b.status === 'offline')
81+ .sort((a, b) => a.nickname.localeCompare(b.nickname))
82+ );
83+ const availableUsers = $derived(
84+ (usersQuery?.data ?? [])
85+ .filter((u) => u.id !== me?.id && !buddies.some((b) => b.id === u.id))
86+ .sort((a, b) => a.nickname.localeCompare(b.nickname))
87+ );
88+ const selectedBuddy = $derived(buddies.find((b) => b.id === selectedId));
89+
90+ onMount(() => {
91+ try {
92+ const stored = JSON.parse(localStorage.getItem('pdraim-buddylist-collapsed') ?? '[]');
93+ if (Array.isArray(stored))
94+ for (const name of stored) if (typeof name === 'string') collapsed.add(name);
95+ } catch {
96+ /* Ignore malformed saved preferences. */
97+ }
98+ return () => {
99+ for (const timer of timers.values()) clearTimeout(timer);
100+ };
101+ });
102+
103+ $effect(() => {
104+ const data = buddyQuery?.data;
105+ if (!me || !data) {
106+ previous = new SvelteMap<string, AimStatus>();
107+ initialized = false;
108+ return;
109+ }
110+ const next = new SvelteMap<string, AimStatus>();
111+ for (const buddy of data) {
112+ next.set(buddy.id, buddy.status);
113+ const old = previous.get(buddy.id);
114+ if (!initialized || old === undefined || old === buddy.status) continue;
115+ if (old === 'offline' && buddy.status !== 'offline') {
116+ playSound('doorOpen');
117+ flashing.add(buddy.id);
118+ const existing = timers.get(buddy.id);
119+ if (existing) clearTimeout(existing);
120+ timers.set(
121+ buddy.id,
122+ setTimeout(() => {
123+ flashing.delete(buddy.id);
124+ timers.delete(buddy.id);
125+ }, 1500)
126+ );
127+ } else if (old !== 'offline' && buddy.status === 'offline') playSound('doorSlam');
128+ }
129+ previous = next;
130+ initialized = true;
131+ });
132+
133+ function toggleGroup(name: string) {
134+ if (collapsed.has(name)) collapsed.delete(name);
135+ else collapsed.add(name);
136+ localStorage.setItem('pdraim-buddylist-collapsed', JSON.stringify([...collapsed]));
137+ }
138+ function openPanel(which: 'im' | 'add') {
139+ panel = panel === which ? null : which;
140+ error = '';
141+ dropdownMenu = null;
142+ }
143+ async function openIm(id: Id<'users'>, name: string) {
144+ try {
145+ const room = await aim.getOrCreateDirectRoom(id);
146+ desktop.openIm({ roomId: room.id, otherUserId: id, otherNickname: name });
147+ panel = null;
148+ } catch {
149+ error = 'Impossible d’ouvrir la conversation.';
150+ }
151+ }
152+ async function startIm() {
153+ const user = usersQuery?.data?.find(
154+ (u) => u.nickname.toLocaleLowerCase() === nickname.trim().toLocaleLowerCase()
155+ );
156+ if (!user) {
157+ error = 'Pseudo introuvable.';
158+ return;
159+ }
160+ await openIm(user.id, user.nickname);
161+ }
162+ async function addBuddy() {
163+ const user = availableUsers.find((u) => u.id === addId);
164+ const group = addGroup === 'Autre…' ? customGroup.trim() : addGroup;
165+ if (!user || !group) {
166+ error = 'Choisissez un contact et un groupe.';
167+ return;
168+ }
169+ try {
170+ await aim.addBuddy(user.id, group);
171+ panel = null;
172+ addId = '';
173+ error = '';
174+ } catch {
175+ error = 'Impossible d’ajouter ce contact.';
176+ }
177+ }
178+ async function removeBuddy(id: Id<'users'>) {
179+ context = null;
180+ try {
181+ await aim.removeBuddy(id);
182+ if (selectedId === id) selectedId = null;
183+ } catch {
184+ error = 'Impossible de retirer ce contact.';
185+ }
186+ }
187+ function showContext(event: MouseEvent, buddy: Buddy) {
188+ event.preventDefault();
189+ selectedId = buddy.id;
190+ dropdownMenu = null;
191+ context = {
192+ buddy,
193+ x: Math.min(event.clientX, window.innerWidth - 190),
194+ y: Math.min(event.clientY, window.innerHeight - 95)
195+ };
196+ }
197+ function outside(event: MouseEvent) {
198+ const target = event.target as Node;
199+ if (dropdownMenu && !menuBar?.contains(target)) dropdownMenu = null;
200+ if (context && !contextElement?.contains(target)) context = null;
201+ }
202+ function escape(event: KeyboardEvent) {
203+ if (event.key === 'Escape') {
204+ dropdownMenu = null;
205+ context = null;
206+ panel = null;
207+ }
208+ }
209+ async function logout() {
210+ dropdownMenu = null;
211+ try {
212+ const response = await fetch('/api/session/logout', { method: 'POST' });
213+ if (!response.ok) throw new Error('logout failed');
214+ window.location.reload();
215+ } catch {
216+ error = 'Déconnexion impossible.';
217+ }
218+ }
219+ function tooltip(buddy: Buddy) {
220+ return `${STATUS_LABELS_FR[buddy.status]}${buddy.status === 'offline' && buddy.lastSeen ? ` — Dernière visite : ${new Date(buddy.lastSeen).toLocaleString('fr-FR')}` : ''}`;
221+ }
222+</script>
223+
224+<svelte:window onclick={outside} onkeydown={escape} />
225+
226+{#snippet buddyRow(buddy: Buddy)}
227+ <div class="row" class:selected={selectedId === buddy.id} class:flash={flashing.has(buddy.id)}>
228+ <button
229+ class="row-action"
230+ class:offline={buddy.status === 'offline'}
231+ class:away={buddy.status === 'away'}
232+ title={tooltip(buddy)}
233+ onclick={() => (selectedId = buddy.id)}
234+ ondblclick={() => openIm(buddy.id, buddy.nickname)}
235+ oncontextmenu={(e) => showContext(e, buddy)}
236+ >
237+ <img src={`/aim/status-${buddy.status}.svg`} alt="" width="12" height="12" />
238+ <span class="truncate">{buddy.nickname}{buddy.status === 'idle' ? ' (Inactif)' : ''}</span>
239+ {#if buddy.status === 'away'}<img
240+ src="/aim/xp-note-16.png"
241+ alt=""
242+ width="12"
243+ height="12"
244+ />{/if}
245+ </button>
246+ {#if editMode}<button
247+ class="remove"
248+ aria-label={`Retirer ${buddy.nickname}`}
249+ title={`Retirer ${buddy.nickname}`}
250+ onclick={() => removeBuddy(buddy.id)}>✕</button
251+ >{/if}
252+ </div>
253+{/snippet}
254+
255+<XpWindow
256+ id="buddy-list"
257+ width={BUDDY_LIST_WIDTH}
258+ height={buddyListHeight}
259+ minWidth={BUDDY_LIST_WIDTH}
260+ showMaximize={false}
261+ x={browser ? Math.max(8, window.innerWidth - BUDDY_LIST_WIDTH - 24) : undefined}
262+ y={browser ? 40 : undefined}
263+>
264+ {#snippet menu()}
265+ <div class="menus" bind:this={menuBar}>
266+ <div class="menu-wrap">
267+ <button
268+ aria-expanded={dropdownMenu === 'aim'}
269+ onclick={() => (dropdownMenu = dropdownMenu === 'aim' ? null : 'aim')}>Mon AIM</button
270+ >
271+ {#if dropdownMenu === 'aim'}<div class="dropdown">
272+ <button
273+ onclick={() => {
274+ dropdownMenu = null;
275+ desktop.openAway();
276+ }}>Message d’absence…</button
277+ >
278+ <button
279+ disabled={!me}
280+ onclick={() => {
281+ dropdownMenu = null;
282+ if (me)
283+ desktop.openProfile({ userId: me.id as Id<'users'>, nickname: me.nickname });
284+ }}>Modifier mon profil…</button
285+ >
286+ <button
287+ onclick={() => {
288+ dropdownMenu = null;
289+ desktop.openPreferences();
290+ }}>Préférences…</button
291+ >
292+ <hr />
293+ <button onclick={logout}>Se déconnecter</button>
294+ </div>{/if}
295+ </div>
296+ <div class="menu-wrap">
297+ <button
298+ aria-expanded={dropdownMenu === 'contacts'}
299+ onclick={() => (dropdownMenu = dropdownMenu === 'contacts' ? null : 'contacts')}
300+ >Contacts</button
301+ >
302+ {#if dropdownMenu === 'contacts'}<div class="dropdown">
303+ <button onclick={() => openPanel('add')}>Ajouter un contact…</button><button
304+ onclick={() => {
305+ dropdownMenu = null;
306+ desktop.openNewRoom();
307+ }}>Nouveau salon…</button
308+ >
309+ </div>{/if}
310+ </div>
311+ <div class="menu-wrap">
312+ <button
313+ aria-expanded={dropdownMenu === 'help'}
314+ onclick={() => (dropdownMenu = dropdownMenu === 'help' ? null : 'help')}>Aide</button
315+ >
316+ {#if dropdownMenu === 'help'}<div class="dropdown">
317+ <button
318+ onclick={() => {
319+ dropdownMenu = null;
320+ desktop.openAbout();
321+ }}>À propos de PDR AIM</button
322+ >
323+ </div>{/if}
324+ </div>
325+ </div>
326+ {/snippet}
327+ <div class="buddy-content">
328+ <header class="identity">
329+ {#if me}
330+ <img class="icon" src="/aim/running-man-32.png" alt="" width="32" height="32" />
331+ <div class="identity-text">
332+ <strong>{me.nickname}</strong><span title={meQuery?.data?.awayMessage ?? ''}
333+ >{meQuery?.data?.status === 'away'
334+ ? `Absent${meQuery.data.awayMessage ? ` : ${meQuery.data.awayMessage}` : ''}`
335+ : (STATUS_LABELS_FR[(meQuery?.data?.status ?? me.status) as AimStatus] ??
336+ 'En ligne')}</span
337+ >
338+ </div>
339+ {:else}
340+ <div>
341+ <button onclick={() => desktop.openLogin('signin')}>Se connecter</button>
342+ <p>Connectez-vous pour voir vos contacts.</p>
343+ </div>
344+ {/if}
345+ </header>
346+ <div class="tabs">
347+ <button class:active={!editMode} aria-pressed={!editMode} onclick={() => (editMode = false)}
348+ >Online</button
349+ ><button class:active={editMode} aria-pressed={editMode} onclick={() => (editMode = true)}
350+ >List Setup</button
351+ >
352+ </div>
353+ <div class="sunken-panel tree">
354+ {#if me}
355+ {#each sortedGroups as group (group.name)}
356+ <button
357+ class="group"
358+ aria-expanded={!collapsed.has(group.name)}
359+ onclick={() => toggleGroup(group.name)}
360+ >{collapsed.has(group.name) ? '▶' : '▼'}
361+ {group.name} ({group.members.filter((buddy) => buddy.status === 'online')
362+ .length}/{group.total})</button
363+ >
364+ {#if !collapsed.has(group.name)}{#each group.members as buddy (buddy.id)}{@render buddyRow(
365+ buddy
366+ )}{/each}{/if}
367+ {/each}
368+ <button
369+ class="group"
370+ aria-expanded={!collapsed.has('Offline')}
371+ onclick={() => toggleGroup('Offline')}
372+ >{collapsed.has('Offline') ? '▶' : '▼'} Offline ({offline.length})</button
373+ >
374+ {#if !collapsed.has('Offline')}{#each offline as buddy (buddy.id)}{@render buddyRow(
375+ buddy
376+ )}{/each}{/if}
377+ <button
378+ class="group"
379+ aria-expanded={!collapsed.has('Salons')}
380+ onclick={() => toggleGroup('Salons')}
381+ >{collapsed.has('Salons') ? '▶' : '▼'} Salons ({roomsQuery?.data?.length ?? 0})</button
382+ >
383+ {#if !collapsed.has('Salons')}{#each [...(roomsQuery?.data ?? [])].sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name)) as room (room.id)}<button
384+ class="list-entry"
385+ onclick={() => desktop.openChatRoom(room.id, room.name)}
386+ ><img src="/aim/chat-room-16.png" alt="" width="12" height="12" />
387+ <span class="truncate">{room.name}</span></button
388+ >{/each}{/if}
389+ <button
390+ class="group"
391+ aria-expanded={!collapsed.has('Conversations')}
392+ onclick={() => toggleGroup('Conversations')}
393+ >{collapsed.has('Conversations') ? '▶' : '▼'} Conversations ({directQuery?.data?.length ??
394+ 0})</button
395+ >
396+ {#if !collapsed.has('Conversations')}{#each directQuery?.data ?? [] as room (room.id)}{#if room.other}<button
397+ class="list-entry"
398+ class:unread={room.unreadCount > 0}
399+ onclick={() =>
400+ desktop.openIm({
401+ roomId: room.id,
402+ otherUserId: room.other!.id,
403+ otherNickname: room.other!.nickname
404+ })}
405+ ><img src="/aim/running-man-16.png" alt="" width="12" height="12" />
406+ <span class="truncate"
407+ >{room.other.nickname}{room.unreadCount
408+ ? ` (${room.unreadCount >= 100 ? '99+' : room.unreadCount})`
409+ : ''}</span
410+ ></button
411+ >{/if}{/each}{/if}
412+ {/if}
413+ </div>
414+ {#if me && panel === 'im'}<form
415+ class="inline-panel"
416+ onsubmit={(e) => {
417+ e.preventDefault();
418+ void startIm();
419+ }}
420+ >
421+ <label for="im-nick">Pseudo du contact</label><input
422+ id="im-nick"
423+ type="text"
424+ bind:value={nickname}
425+ autocomplete="off"
426+ /><button type="submit">Ouvrir IM</button>
427+ </form>{/if}
428+ {#if me && panel === 'add'}<form
429+ class="inline-panel"
430+ onsubmit={(e) => {
431+ e.preventDefault();
432+ void addBuddy();
433+ }}
434+ >
435+ <label for="add-user">Contact</label><select id="add-user" bind:value={addId}
436+ ><option value="">Choisir…</option>{#each availableUsers as user (user.id)}<option
437+ value={user.id}>{user.nickname}</option
438+ >{/each}</select
439+ ><label for="add-group">Groupe</label><select id="add-group" bind:value={addGroup}
440+ >{#each DEFAULT_BUDDY_GROUPS as group (group)}<option value={group}>{group}</option
441+ >{/each}<option value="Autre…">Autre…</option></select
442+ >{#if addGroup === 'Autre…'}<input
443+ type="text"
444+ aria-label="Nom du groupe"
445+ bind:value={customGroup}
446+ />{/if}<button type="submit">Ajouter</button>
447+ </form>{/if}
448+ {#if error}<p class="error" role="alert">{error}</p>{/if}
449+ <div class="toolbar">
450+ <button disabled={!me} onclick={() => openPanel('im')}
451+ ><img src="/aim/running-man-48.png" alt="" />IM</button
452+ >
453+ <button disabled={!me} onclick={() => desktop.openNewRoom()}
454+ ><img src="/aim/chat-room-48.png" alt="" />Chat</button
455+ >
456+ <button
457+ disabled={!selectedBuddy}
458+ onclick={() => {
459+ if (selectedBuddy)
460+ desktop.openProfile({ userId: selectedBuddy.id, nickname: selectedBuddy.nickname });
461+ }}><img src="/aim/profile-48.png" alt="" />Infos</button
462+ >
463+ <button disabled={!me} onclick={() => desktop.openAway()}
464+ ><img src="/aim/xp-note-48.png" alt="" />Absent</button
465+ >
466+ <button disabled={!me} onclick={() => openPanel('add')}
467+ ><img src="/aim/add-buddy-48.png" alt="" />Ajouter</button
468+ >
469+ </div>
470+ </div>
471+</XpWindow>
472+
473+{#if context}<div
474+ class="context dropdown"
475+ bind:this={contextElement}
476+ style:left={`${Math.max(0, context.x)}px`}
477+ style:top={`${Math.max(0, context.y)}px`}
478+ >
479+ <button
480+ onclick={() => {
481+ if (context) void openIm(context.buddy.id, context.buddy.nickname);
482+ context = null;
483+ }}>Envoyer un message instantané</button
484+ >
485+ <button
486+ onclick={() => {
487+ if (context)
488+ desktop.openProfile({ userId: context.buddy.id, nickname: context.buddy.nickname });
489+ context = null;
490+ }}>Infos sur {context.buddy.nickname}</button
491+ >
492+ <button
493+ onclick={() => {
494+ if (context) void removeBuddy(context.buddy.id);
495+ }}>Retirer de la liste</button
496+ >
497+ </div>{/if}
498+
499+<style>
500+ .buddy-content {
501+ display: flex;
502+ flex-direction: column;
503+ flex: 1;
504+ min-height: 0;
505+ font:
506+ 11px Tahoma,
507+ 'Pixelated MS Sans Serif',
508+ sans-serif;
509+ }
510+ .identity {
511+ display: flex;
512+ align-items: center;
513+ gap: 6px;
514+ min-height: 49px;
515+ padding: 4px;
516+ background: linear-gradient(#ffe880, #f6c700);
517+ border-bottom: 1px solid #c59d00;
518+ }
519+ .identity p {
520+ margin: 3px 0 0;
521+ }
522+ .icon {
523+ flex: none;
524+ }
525+ .identity-text {
526+ min-width: 0;
527+ display: flex;
528+ flex-direction: column;
529+ gap: 3px;
530+ }
531+ .identity-text strong {
532+ display: flex;
533+ align-items: center;
534+ white-space: nowrap;
535+ overflow: hidden;
536+ text-overflow: ellipsis;
537+ }
538+ .identity-text span,
539+ .truncate {
540+ overflow: hidden;
541+ text-overflow: ellipsis;
542+ white-space: nowrap;
543+ }
544+ .tabs {
545+ display: flex;
546+ padding-top: 4px;
547+ }
548+ .tabs button {
549+ border: 1px solid #aaa;
550+ border-bottom: 0;
551+ border-radius: 4px 4px 0 0;
552+ padding: 3px 9px;
553+ min-width: 0;
554+ font-size: 11px;
555+ background: #dedbd0;
556+ }
557+ .tabs button.active {
558+ background: #fff;
559+ font-weight: bold;
560+ }
561+ .tree {
562+ flex: 1;
563+ min-height: 0;
564+ overflow: auto;
565+ background: #fff;
566+ padding: 2px;
567+ }
568+ .group,
569+ .list-entry,
570+ .row-action {
571+ display: flex;
572+ align-items: center;
573+ width: 100%;
574+ min-width: 0;
575+ text-align: left;
576+ border: 0;
577+ border-radius: 0;
578+ box-shadow: none;
579+ font: inherit;
580+ cursor: default;
581+ }
582+ .group {
583+ background: #ece9d8;
584+ font-weight: bold;
585+ padding: 3px 2px;
586+ margin-top: 2px;
587+ }
588+ .list-entry,
589+ .row-action {
590+ background: transparent;
591+ padding: 2px 2px 2px 12px;
592+ gap: 4px;
593+ }
594+ .list-entry:hover,
595+ .row-action:hover,
596+ .row.selected .row-action {
597+ background: #316ac5;
598+ color: #fff;
599+ }
600+ .row {
601+ display: flex;
602+ align-items: center;
603+ }
604+ .row-action {
605+ flex: 1;
606+ }
607+ .row-action.offline,
608+ .row-action.away {
609+ color: #777;
610+ font-style: italic;
611+ }
612+ .row-action:hover,
613+ .row.selected .row-action {
614+ color: #fff;
615+ }
616+ .row-action img,
617+ .list-entry img {
618+ flex: none;
619+ }
620+ .remove {
621+ min-width: 17px;
622+ padding: 0;
623+ border: 0;
624+ box-shadow: none;
625+ color: #900;
626+ }
627+ .unread {
628+ font-weight: bold;
629+ }
630+ .inline-panel {
631+ display: flex;
632+ flex-direction: column;
633+ gap: 3px;
634+ padding: 4px;
635+ background: #ece9d8;
636+ }
637+ .inline-panel input,
638+ .inline-panel select {
639+ width: 100%;
640+ box-sizing: border-box;
641+ }
642+ .error {
643+ color: #a00;
644+ margin: 2px;
645+ }
646+ .toolbar {
647+ display: flex;
648+ justify-content: space-between;
649+ border-top: 1px solid #aca899;
650+ padding-top: 3px;
651+ }
652+ .toolbar button {
653+ display: flex;
654+ align-items: center;
655+ flex-direction: column;
656+ gap: 2px;
657+ min-width: 0;
658+ padding: 2px;
659+ font: inherit;
660+ }
661+ .toolbar img {
662+ width: 24px;
663+ height: 24px;
664+ }
665+ .menus {
666+ display: flex;
667+ }
668+ .menu-wrap {
669+ position: relative;
670+ }
671+ .dropdown {
672+ position: absolute;
673+ z-index: 10000;
674+ top: 100%;
675+ left: 0;
676+ min-width: 170px;
677+ padding: 2px;
678+ background: #fff;
679+ border: 1px solid #aca899;
680+ box-shadow: 2px 2px 4px #888;
681+ }
682+ .dropdown button {
683+ display: block;
684+ width: 100%;
685+ min-width: 0;
686+ padding: 4px 8px;
687+ text-align: left;
688+ border: 0;
689+ box-shadow: none;
690+ background: #fff;
691+ color: #000;
692+ white-space: nowrap;
693+ font:
694+ 11px Tahoma,
695+ sans-serif;
696+ }
697+ .dropdown button:hover:not(:disabled) {
698+ background: #316ac5;
699+ color: #fff;
700+ }
701+ .dropdown hr {
702+ margin: 2px 4px;
703+ }
704+ .context {
705+ position: fixed;
706+ top: auto;
707+ min-width: 185px;
708+ }
709+ @keyframes sign-on {
710+ 0%,
711+ 75% {
712+ background: #fff199;
713+ }
714+ 100% {
715+ background: transparent;
716+ }
717+ }
718+ .row.flash {
719+ animation: sign-on 1.5s ease-out;
720+ }
721+</style>
A src/lib/components/desktop/chat-rooms-window.svelte +163 -0
@@ -0,0 +1,163 @@
1+<script lang="ts">
2+ import ChatRoom from '../chat-room.svelte';
3+ import XpWindow from './xp-window.svelte';
4+ import { desktop, CHAT_WINDOW_ID } from '$lib/states/desktop.svelte';
5+ import { chatState } from '$lib/states/chat.svelte';
6+ import type { TextStyle } from '$lib/types/text-formatting';
7+ import { useQuery } from 'convex-svelte';
8+ import { api } from '../../../convex/_generated/api';
9+ import { convexAvailable } from '$lib/aim/client';
10+
11+ let { initialTextStyle }: { initialTextStyle?: TextStyle } = $props();
12+
13+ const currentUser = $derived(chatState.getCurrentUser());
14+ let showUserList = $state(false);
15+
16+ // Room topics show as tab tooltips
17+ const roomsQuery = convexAvailable ? useQuery(api.aim.getGroupRooms, {}) : null;
18+ const topicOf = (roomId: string | null) =>
19+ roomsQuery?.data?.find((r) => (roomId ? r.id === roomId : r.isDefault))?.topic;
20+
21+ const tabKey = (roomId: string | null) => roomId ?? 'default';
22+</script>
23+
24+<XpWindow
25+ id={CHAT_WINDOW_ID}
26+ width={800}
27+ height={600}
28+ minWidth={420}
29+ minHeight={420}
30+ showMaximize
31+ bodyClass="chat-rooms-body"
32+>
33+ {#snippet menu()}
34+ <button type="button" onclick={() => desktop.openBuddyList()}>Contacts</button>
35+ <button type="button" onclick={() => desktop.openNewRoom()}>Nouveau salon</button>
36+ <button type="button" onclick={() => (showUserList = !showUserList)}>Participants</button>
37+ {#if !currentUser}
38+ <button type="button" onclick={() => desktop.openLogin('signin')}>Se connecter</button>
39+ {/if}
40+ {/snippet}
41+
42+ <menu role="tablist" class="room-tabs">
43+ {#each desktop.roomTabs as tab (tabKey(tab.roomId))}
44+ {@const active = tab.roomId === desktop.activeRoomId}
45+ <button
46+ type="button"
47+ role="tab"
48+ aria-selected={active}
49+ aria-controls="room-panel-{tabKey(tab.roomId)}"
50+ title={topicOf(tab.roomId) ?? tab.roomName}
51+ onclick={() => desktop.selectRoomTab(tab.roomId)}
52+ onkeydown={(e) => {
53+ if (e.key === 'Delete' && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId);
54+ }}
55+ onauxclick={(e) => {
56+ if (e.button === 1 && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId);
57+ }}
58+ >
59+ <img src="/aim/chat-room-16.png" alt="" width="14" height="14" />
60+ <span>{tab.roomName}</span>
61+ </button>
62+ {#if desktop.roomTabs.length > 1}
63+ <!-- Sibling of the tab (a button cannot contain another button) -->
64+ <button
65+ type="button"
66+ class="tab-close"
67+ class:active
68+ aria-label="Fermer {tab.roomName}"
69+ title="Fermer {tab.roomName}"
70+ onclick={() => desktop.closeRoomTab(tab.roomId)}>×</button
71+ >
72+ {/if}
73+ {/each}
74+ </menu>
75+
76+ <div class="tab-panels">
77+ {#each desktop.roomTabs as tab (tabKey(tab.roomId))}
78+ <!-- Every open room stays mounted so switching tabs keeps scroll and draft -->
79+ <div
80+ id="room-panel-{tabKey(tab.roomId)}"
81+ role="tabpanel"
82+ class="tab-panel"
83+ hidden={tab.roomId !== desktop.activeRoomId}
84+ >
85+ <ChatRoom roomId={tab.roomId} {initialTextStyle} {showUserList} />
86+ </div>
87+ {/each}
88+ </div>
89+</XpWindow>
90+
91+<style>
92+ .room-tabs {
93+ flex: 0 0 auto;
94+ margin-top: 2px;
95+ }
96+
97+ .room-tabs button {
98+ display: flex;
99+ align-items: center;
100+ gap: 4px;
101+ padding: 2px 8px 3px;
102+ font:
103+ 11px Tahoma,
104+ 'Pixelated MS Sans Serif',
105+ sans-serif;
106+ max-width: 180px;
107+ }
108+
109+ .room-tabs button span:first-of-type {
110+ overflow: hidden;
111+ text-overflow: ellipsis;
112+ white-space: nowrap;
113+ }
114+
115+ .room-tabs button.tab-close {
116+ margin-left: -1px;
117+ padding: 0 4px;
118+ line-height: 12px;
119+ color: #555;
120+ cursor: default;
121+ }
122+
123+ .room-tabs button.tab-close.active {
124+ background-color: #ece9d8;
125+ position: relative;
126+ z-index: 8;
127+ margin-top: -2px;
128+ margin-bottom: 1px;
129+ }
130+
131+ .tab-close:hover {
132+ background: #c75050;
133+ color: #fff;
134+ }
135+
136+ .tab-panels {
137+ flex: 1 1 auto;
138+ min-height: 0;
139+ display: flex;
140+ flex-direction: column;
141+ background: #fcfcfe;
142+ border: 1px solid #919b9c;
143+ padding: 4px;
144+ position: relative;
145+ z-index: 2;
146+ }
147+
148+ /* xp.css frames every [role=tabpanel]; the outer .tab-panels is the frame here */
149+ .tab-panel {
150+ padding: 0;
151+ margin: 0;
152+ border: 0;
153+ background: transparent;
154+ flex: 1 1 auto;
155+ min-height: 0;
156+ display: flex;
157+ flex-direction: column;
158+ }
159+
160+ .tab-panel[hidden] {
161+ display: none;
162+ }
163+</style>
A src/lib/components/desktop/im-window.svelte +478 -0
@@ -0,0 +1,478 @@
1+<script lang="ts">
2+ import { onMount, tick } from 'svelte';
3+ import { useQuery } from 'convex-svelte';
4+ import { api as convexApi } from '../../../convex/_generated/api';
5+ import type { Id } from '../../../convex/_generated/dataModel';
6+ import type { EnrichedMessage, SafeUser } from '$lib/types/chat';
7+ import { authQuery, useAimClient } from '$lib/aim/client';
8+ import { playSound } from '$lib/aim/sounds.svelte';
9+ import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
10+ import { chatState } from '$lib/states/chat.svelte';
11+ import { desktop } from '$lib/states/desktop.svelte';
12+ import { api } from '$lib/api/client';
13+ import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting';
14+ import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
15+ import { formatFrenchTime } from '$lib/utils/date-format';
16+ import XpWindow from './xp-window.svelte';
17+ import FormattedMessage from '../formatted-message.svelte';
18+ import TextFormattingToolbar from '../text-formatting-toolbar.svelte';
19+
20+ let { roomId, otherUserId, otherNickname } = $props<{
21+ roomId: Id<'chatRooms'>;
22+ otherUserId: Id<'users'>;
23+ otherNickname: string;
24+ }>();
25+ const id = $derived(`im-${roomId}`);
26+ const aim = useAimClient();
27+ const messagesQuery = authQuery(convexApi.aim.getRoomMessages, () => ({ roomId }));
28+ const typingQuery = authQuery(convexApi.aim.getRoomTyping, () => ({ roomId }));
29+ const profileQuery = useQuery(convexApi.aim.getUserProfile, () => ({ userId: otherUserId }));
30+ const currentUser = $derived(chatState.getCurrentUser());
31+ const profile = $derived(profileQuery.data);
32+ const status = $derived((profile?.status ?? 'offline') as AimStatus);
33+ const messages = $derived.by<EnrichedMessage[]>(() =>
34+ (messagesQuery?.data ?? []).map((msg) => ({
35+ id: msg.id,
36+ chatRoomId: msg.chatRoomId,
37+ senderId: msg.senderId,
38+ content: msg.content,
39+ type: msg.type,
40+ timestamp: msg.timestamp,
41+ styleData: msg.styleData,
42+ hasFormatting: msg.hasFormatting ?? false,
43+ user: {
44+ id: msg.sender?.id ?? msg.senderId,
45+ nickname: msg.sender?.nickname ?? otherNickname,
46+ status: (msg.sender?.status ?? 'offline') as SafeUser['status'],
47+ avatarUrl: msg.sender?.avatarUrl ?? null,
48+ lastSeen: null
49+ }
50+ }))
51+ );
52+ let menuOpen = $state<'file' | 'contact' | null>(null);
53+ let draft = $state('');
54+ let textStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE });
55+ let styleReady = $state(false);
56+ let loadedStyle = $state('');
57+ let sending = $state(false);
58+ let error = $state('');
59+ let cooldownSeconds = $state(0);
60+ let cooldownEnd = 0;
61+ let cooldownTimer: ReturnType<typeof setInterval> | undefined;
62+ let typingTimer: ReturnType<typeof setTimeout> | undefined;
63+ let readTimer: ReturnType<typeof setTimeout> | undefined;
64+ let lastTypingAt = 0;
65+ let conversation: HTMLDivElement;
66+ let firstScroll = true;
67+ let previousLast: string | null = null;
68+ let previousRoom: string | null = null;
69+ const disabled = $derived(!currentUser || sending || cooldownSeconds > 0);
70+
71+ // Load once per signed-in identity; do not persist the fetched default as an edit.
72+ $effect(() => {
73+ const userId = currentUser?.id;
74+ styleReady = false;
75+ if (!userId) return;
76+ let active = true;
77+ void api.textPreferences
78+ .get()
79+ .then((prefs) => {
80+ if (!active) return;
81+ textStyle = { ...DEFAULT_TEXT_STYLE, ...prefs?.defaultStyle };
82+ loadedStyle = JSON.stringify(textStyle);
83+ styleReady = true;
84+ })
85+ .catch(() => {
86+ if (!active) return;
87+ loadedStyle = JSON.stringify(textStyle);
88+ styleReady = true;
89+ });
90+ return () => {
91+ active = false;
92+ };
93+ });
94+ // Debounced save: the effect cleanup cancels a pending save on unmount, identity
95+ // change or a newer edit, so a style is never saved under another session.
96+ $effect(() => {
97+ const style = JSON.stringify(textStyle);
98+ if (!styleReady || !currentUser || style === loadedStyle) return;
99+ const timer = setTimeout(() => {
100+ loadedStyle = style;
101+ api.textPreferences
102+ .save({
103+ defaultStyle: JSON.parse(style) as TextStyle,
104+ allowFormatting: true,
105+ maxMessageLength: MAX_MESSAGE_LENGTH
106+ })
107+ .catch((err) => console.debug('Style save failed', err));
108+ }, 1500);
109+ return () => clearTimeout(timer);
110+ });
111+
112+ function stopTyping() {
113+ if (typingTimer) clearTimeout(typingTimer);
114+ typingTimer = undefined;
115+ if (lastTypingAt) {
116+ lastTypingAt = 0;
117+ void aim.setTyping(roomId, false).catch(() => {});
118+ }
119+ }
120+ function onInput() {
121+ if (!currentUser) return;
122+ if (!draft.trim()) {
123+ stopTyping();
124+ return;
125+ }
126+ const now = Date.now();
127+ if (draft.trim() && now - lastTypingAt >= 3000) {
128+ lastTypingAt = now;
129+ void aim.setTyping(roomId, true).catch(() => {});
130+ }
131+ if (typingTimer) clearTimeout(typingTimer);
132+ typingTimer = setTimeout(stopTyping, 4000);
133+ }
134+ function startCooldown(retryAfter: number) {
135+ cooldownEnd = Date.now() + retryAfter;
136+ if (cooldownTimer) clearInterval(cooldownTimer);
137+ const update = () => {
138+ cooldownSeconds = Math.max(0, Math.ceil((cooldownEnd - Date.now()) / 1000));
139+ if (!cooldownSeconds && cooldownTimer) {
140+ clearInterval(cooldownTimer);
141+ cooldownTimer = undefined;
142+ }
143+ };
144+ update();
145+ if (cooldownSeconds) cooldownTimer = setInterval(update, 200);
146+ }
147+ async function send() {
148+ if (disabled || !draft.trim() || draft.length > MAX_MESSAGE_LENGTH || !currentUser) return;
149+ sending = true;
150+ error = '';
151+ const content = draft;
152+ try {
153+ const result = await api.chat.sendMessage({
154+ content,
155+ type: 'chat',
156+ userId: currentUser.id,
157+ chatRoomId: roomId,
158+ styleData: JSON.stringify(textStyle)
159+ });
160+ if (result.success) {
161+ if (draft === content) draft = '';
162+ stopTyping();
163+ playSound('imSend');
164+ } else if (result.isRateLimited && result.retryAfter) {
165+ startCooldown(result.retryAfter);
166+ } else {
167+ error = result.error ?? 'Impossible d’envoyer le message.';
168+ }
169+ } catch {
170+ error = 'Impossible d’envoyer le message.';
171+ } finally {
172+ sending = false;
173+ }
174+ }
175+ function onKeydown(event: KeyboardEvent) {
176+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
177+ event.preventDefault();
178+ void send();
179+ }
180+ }
181+
182+ $effect(() => {
183+ const room = roomId;
184+ const data = messagesQuery?.data;
185+ if (room !== previousRoom) {
186+ previousRoom = room;
187+ previousLast = null;
188+ firstScroll = true;
189+ }
190+ if (!data) return;
191+ const last = data.at(-1);
192+ const signature = last ? `${last.id}:${last.timestamp}` : '';
193+ if (
194+ previousLast !== null &&
195+ last &&
196+ signature !== previousLast &&
197+ last.senderId === otherUserId
198+ ) {
199+ playSound('imReceive');
200+ if (desktop.focusedId !== id) desktop.requestAttention(id);
201+ }
202+ const changed = signature !== previousLast;
203+ previousLast = signature;
204+ if (!changed || !conversation) return;
205+ const shouldScroll =
206+ firstScroll ||
207+ conversation.scrollTop + conversation.clientHeight >= conversation.scrollHeight - 40;
208+ firstScroll = false;
209+ if (shouldScroll) void tick().then(() => conversation?.scrollTo(0, conversation.scrollHeight));
210+ });
211+ $effect(() => {
212+ const last = messagesQuery?.data?.at(-1);
213+ const focused = desktop.focusedId === id && !desktop.get(id)?.minimized;
214+ if (!focused || !currentUser || !messagesQuery?.data) return;
215+ // Depend on the latest message and focus, not the mutation's result.
216+ void last;
217+ readTimer = setTimeout(() => {
218+ void aim.markRoomRead(roomId).catch(() => {});
219+ }, 500);
220+ return () => {
221+ if (readTimer) clearTimeout(readTimer);
222+ };
223+ });
224+ onMount(() => () => {
225+ stopTyping();
226+ if (cooldownTimer) clearInterval(cooldownTimer);
227+ if (readTimer) clearTimeout(readTimer);
228+ });
229+</script>
230+
231+<XpWindow {id} width={460} height={420} minWidth={320} minHeight={300} showMaximize>
232+ {#snippet menu()}
233+ <div class="menu-item">
234+ <button
235+ aria-expanded={menuOpen === 'file'}
236+ onclick={() => (menuOpen = menuOpen === 'file' ? null : 'file')}>Fichier</button
237+ >
238+ {#if menuOpen === 'file'}<div class="dropdown">
239+ <button
240+ onclick={() => {
241+ menuOpen = null;
242+ desktop.close(id);
243+ }}>Fermer</button
244+ >
245+ </div>{/if}
246+ </div>
247+ <div class="menu-item">
248+ <button
249+ aria-expanded={menuOpen === 'contact'}
250+ onclick={() => (menuOpen = menuOpen === 'contact' ? null : 'contact')}>Contact</button
251+ >
252+ {#if menuOpen === 'contact'}<div class="dropdown">
253+ <button
254+ onclick={() => {
255+ menuOpen = null;
256+ desktop.openProfile({ userId: otherUserId, nickname: otherNickname });
257+ }}>Infos sur {otherNickname}</button
258+ >
259+ </div>{/if}
260+ </div>
261+ {/snippet}
262+ <div class="im-body">
263+ <div class="header">
264+ <img class="buddy-icon" src="/aim/running-man-48.png" alt="" width="48" height="48" />
265+ <div class="identity">
266+ <strong>{profile?.nickname ?? otherNickname}</strong><span
267+ ><img src={`/aim/status-${status}.svg`} alt="" width="14" height="14" />
268+ {STATUS_LABELS_FR[status] ?? 'Hors ligne'}</span
269+ >
270+ </div>
271+ </div>
272+ {#if status === 'away' && profile?.awayMessage}<div class="away">
273+ Message d'absence : {profile.awayMessage}
274+ </div>{/if}
275+ <div
276+ class="sunken-panel conversation"
277+ bind:this={conversation}
278+ role="log"
279+ aria-label={`Conversation avec ${otherNickname}`}
280+ >
281+ {#if !messagesQuery?.data}<div class="loading">Chargement…</div>{/if}
282+ {#each messages as message (message.id)}
283+ {#if message.type === 'system'}<div class="system">{message.content}</div>
284+ {:else}<div class="message">
285+ <span
286+ class:me={message.senderId === currentUser?.id}
287+ class="nick"
288+ title={new Date(message.timestamp).toLocaleString('fr-FR')}
289+ >{message.user.nickname}{desktop.showTimestamps
290+ ? ` (${formatFrenchTime(new Date(message.timestamp)).replace('h', ':')})`
291+ : ''}:</span
292+ >
293+ <FormattedMessage {message} allowFormatting={true} />
294+ </div>{/if}
295+ {/each}
296+ {#if typingQuery?.data?.length}<div class="typing">
297+ {typingQuery.data.map((user) => user.nickname).join(', ')} est en train d'écrire<span
298+ class="dots">…</span
299+ >
300+ </div>{/if}
301+ </div>
302+ <TextFormattingToolbar
303+ bind:style={textStyle}
304+ compact={true}
305+ showFontSelector={true}
306+ onSmiley={(code) => {
307+ const needsSpace = draft.length > 0 && !draft.endsWith(' ');
308+ draft = `${draft}${needsSpace ? ' ' : ''}${code} `;
309+ document.getElementById(`im-input-${roomId}`)?.focus();
310+ }}
311+ />
312+ <div class="compose field-row">
313+ <label class="sr-only" for={`im-input-${roomId}`}>Message à {otherNickname}</label>
314+ <textarea
315+ id={`im-input-${roomId}`}
316+ bind:value={draft}
317+ oninput={onInput}
318+ onkeydown={onKeydown}
319+ disabled={!currentUser || cooldownSeconds > 0}
320+ placeholder={currentUser ? 'Écris ton message…' : 'Connecte-toi pour discuter.'}
321+ maxlength={MAX_MESSAGE_LENGTH}
322+ rows="3"
323+ ></textarea>
324+ <button class="send" onclick={send} disabled={disabled || !draft.trim()}>Envoyer</button>
325+ </div>
326+ {#if cooldownSeconds > 0}<span class="notice">Patiente {cooldownSeconds}s…</span
327+ >{:else if error}<span class="notice" role="alert">{error}</span>{/if}
328+ {#if draft.length > MAX_MESSAGE_LENGTH * 0.8}<span class="counter"
329+ >{draft.length}/{MAX_MESSAGE_LENGTH}</span
330+ >{/if}
331+ </div>
332+</XpWindow>
333+
334+<style>
335+ .im-body {
336+ display: flex;
337+ flex-direction: column;
338+ min-height: 0;
339+ height: 100%;
340+ gap: 5px;
341+ font-family: Tahoma, 'Pixelated MS Sans Serif', sans-serif;
342+ }
343+ .menu-item {
344+ position: relative;
345+ }
346+ .dropdown {
347+ position: absolute;
348+ top: 100%;
349+ left: 0;
350+ z-index: 100;
351+ min-width: 160px;
352+ padding: 2px;
353+ background: #fff;
354+ border: 1px solid #aca899;
355+ box-shadow: 2px 2px 4px #777;
356+ }
357+ .dropdown button {
358+ width: 100%;
359+ text-align: left;
360+ white-space: nowrap;
361+ }
362+ .dropdown button:hover {
363+ background: #316ac5;
364+ color: white;
365+ }
366+ .header {
367+ display: flex;
368+ align-items: center;
369+ gap: 9px;
370+ flex: none;
371+ padding: 2px 4px;
372+ }
373+ .buddy-icon {
374+ flex: none;
375+ }
376+ .identity {
377+ display: flex;
378+ flex-direction: column;
379+ gap: 5px;
380+ min-width: 0;
381+ }
382+ .identity strong {
383+ overflow: hidden;
384+ text-overflow: ellipsis;
385+ white-space: nowrap;
386+ }
387+ .identity span {
388+ display: flex;
389+ align-items: center;
390+ gap: 3px;
391+ }
392+ .away {
393+ flex: none;
394+ background: #fff8cc;
395+ border: 1px solid #e1c466;
396+ padding: 5px;
397+ overflow-wrap: anywhere;
398+ }
399+ .conversation {
400+ flex: 1 1 auto;
401+ min-height: 0;
402+ background: white;
403+ overflow: auto;
404+ padding: 6px;
405+ font:
406+ 12px/1.5 Tahoma,
407+ 'Pixelated MS Sans Serif',
408+ sans-serif;
409+ }
410+ .message {
411+ margin-bottom: 5px;
412+ overflow-wrap: anywhere;
413+ white-space: pre-wrap;
414+ }
415+ .nick {
416+ font-weight: bold;
417+ color: #0000c0;
418+ }
419+ .nick.me {
420+ color: #c00000;
421+ }
422+ .system {
423+ text-align: center;
424+ color: #777;
425+ font-style: italic;
426+ margin: 6px 0;
427+ }
428+ .typing,
429+ .loading {
430+ color: #777;
431+ font-style: italic;
432+ }
433+ .dots {
434+ display: inline-block;
435+ animation: blink 1s infinite alternate;
436+ }
437+ @keyframes blink {
438+ from {
439+ opacity: 0.25;
440+ }
441+ to {
442+ opacity: 1;
443+ }
444+ }
445+ .compose {
446+ display: flex;
447+ align-items: stretch;
448+ gap: 6px;
449+ flex: none;
450+ }
451+ textarea {
452+ flex: 1;
453+ min-width: 0;
454+ resize: none;
455+ font:
456+ 12px Tahoma,
457+ sans-serif;
458+ }
459+ .send {
460+ align-self: flex-end;
461+ }
462+ .notice {
463+ color: #9b2600;
464+ }
465+ .counter {
466+ align-self: flex-end;
467+ font-size: 10px;
468+ color: #777;
469+ }
470+ .sr-only {
471+ position: absolute;
472+ width: 1px;
473+ height: 1px;
474+ overflow: hidden;
475+ clip: rect(0, 0, 0, 0);
476+ white-space: nowrap;
477+ }
478+</style>
A src/lib/components/desktop/new-room-window.svelte +97 -0
@@ -0,0 +1,97 @@
1+<script lang="ts">
2+ import XpWindow from './xp-window.svelte';
3+ import { desktop } from '$lib/states/desktop.svelte';
4+ import { chatState } from '$lib/states/chat.svelte';
5+ import { useAimClient } from '$lib/aim/client';
6+
7+ const client = useAimClient();
8+ const signedIn = $derived(Boolean(chatState.getCurrentUser()));
9+ let name = $state('');
10+ let topic = $state('');
11+ let pending = $state(false);
12+ let error = $state('');
13+
14+ async function create() {
15+ const trimmedName = name.trim();
16+ const trimmedTopic = topic.trim();
17+ if (trimmedName.length < 2 || trimmedName.length > 40) {
18+ error = 'Le nom du salon doit contenir entre 2 et 40 caractères.';
19+ return;
20+ }
21+ if (trimmedTopic.length > 120) {
22+ error = 'Le sujet ne peut pas dépasser 120 caractères.';
23+ return;
24+ }
25+ pending = true;
26+ error = '';
27+ try {
28+ const room = await client.createGroupRoom(trimmedName, trimmedTopic || undefined);
29+ desktop.openChatRoom(room.id, trimmedName);
30+ desktop.close('new-room');
31+ } catch (err) {
32+ error = err instanceof Error ? err.message : 'Impossible de créer le salon.';
33+ } finally {
34+ pending = false;
35+ }
36+ }
37+</script>
38+
39+<XpWindow id="new-room" width={340} height={220} resizableWindow={false}>
40+ <div class="content">
41+ {#if signedIn}
42+ <label for="room-name">Nom du salon</label>
43+ <input
44+ id="room-name"
45+ type="text"
46+ maxlength="40"
47+ minlength="2"
48+ bind:value={name}
49+ oninput={() => (error = '')}
50+ />
51+ <label for="room-topic">Sujet (facultatif)</label>
52+ <input
53+ id="room-topic"
54+ type="text"
55+ maxlength="120"
56+ bind:value={topic}
57+ oninput={() => (error = '')}
58+ />
59+ {#if error}<span role="alert">{error}</span>{/if}
60+ {:else}
61+ <p>Connectez-vous pour créer un salon.</p>
62+ {/if}
63+ <div class="actions">
64+ {#if signedIn}<button disabled={pending} onclick={create}>Créer</button>{:else}<button
65+ onclick={() => desktop.openLogin('signin')}>Se connecter</button
66+ >{/if}
67+ <button onclick={() => desktop.close('new-room')}>Annuler</button>
68+ </div>
69+ </div>
70+</XpWindow>
71+
72+<style>
73+ .content {
74+ display: flex;
75+ flex-direction: column;
76+ gap: 7px;
77+ height: 100%;
78+ font:
79+ 11px Tahoma,
80+ 'Pixelated MS Sans Serif',
81+ sans-serif;
82+ }
83+ input {
84+ width: 100%;
85+ box-sizing: border-box;
86+ font: inherit;
87+ }
88+ .actions {
89+ display: flex;
90+ justify-content: flex-end;
91+ gap: 6px;
92+ margin-top: auto;
93+ }
94+ [role='alert'] {
95+ color: #a00;
96+ }
97+</style>
A src/lib/components/desktop/preferences-window.svelte +82 -0
@@ -0,0 +1,82 @@
1+<script lang="ts">
2+ import XpWindow from './xp-window.svelte';
3+ import { desktop } from '$lib/states/desktop.svelte';
4+ import {
5+ soundsEnabled,
6+ setSoundsEnabled,
7+ playSound,
8+ unlockAudio,
9+ type AimSound
10+ } from '$lib/aim/sounds.svelte';
11+
12+ const sounds: { label: string; name: AimSound }[] = [
13+ { label: "Porte qui s'ouvre", name: 'doorOpen' },
14+ { label: 'Porte qui claque', name: 'doorSlam' },
15+ { label: 'Message reçu', name: 'imReceive' },
16+ { label: 'Message envoyé', name: 'imSend' },
17+ { label: 'Bienvenue', name: 'welcome' },
18+ { label: 'Modem 56k', name: 'modem' }
19+ ];
20+ const soundOn = $derived(soundsEnabled());
21+ function changeSound(event: Event) {
22+ const on = (event.currentTarget as HTMLInputElement).checked;
23+ setSoundsEnabled(on);
24+ if (on) unlockAudio();
25+ }
26+ function changeTimestamps(event: Event) {
27+ desktop.setShowTimestamps((event.currentTarget as HTMLInputElement).checked);
28+ }
29+</script>
30+
31+<XpWindow id="preferences" width={380} height={320} resizableWindow={false}>
32+ <div class="content">
33+ <label
34+ ><input type="checkbox" checked={soundOn} onchange={changeSound} /> Sons activés (porte, ding…)</label
35+ >
36+ <div class="sound-list">
37+ {#each sounds as sound (sound.name)}
38+ <div class="sound-row">
39+ <span>{sound.label}</span><button
40+ disabled={!soundOn}
41+ onclick={() => {
42+ unlockAudio();
43+ playSound(sound.name);
44+ }}>Tester</button
45+ >
46+ </div>
47+ {/each}
48+ </div>
49+ <label
50+ ><input type="checkbox" checked={desktop.showTimestamps} onchange={changeTimestamps} /> Afficher
51+ les horodatages</label
52+ >
53+ <div class="actions"><button onclick={() => desktop.close('preferences')}>Fermer</button></div>
54+ </div>
55+</XpWindow>
56+
57+<style>
58+ .content {
59+ display: flex;
60+ flex-direction: column;
61+ gap: 8px;
62+ height: 100%;
63+ overflow: auto;
64+ font:
65+ 11px Tahoma,
66+ 'Pixelated MS Sans Serif',
67+ sans-serif;
68+ }
69+ .sound-list {
70+ display: grid;
71+ gap: 3px;
72+ }
73+ .sound-row {
74+ display: flex;
75+ align-items: center;
76+ justify-content: space-between;
77+ }
78+ .actions {
79+ margin-top: auto;
80+ align-self: flex-end;
81+ }
82+</style>
A src/lib/components/desktop/profile-window.svelte +155 -0
@@ -0,0 +1,155 @@
1+<script lang="ts">
2+ import { useQuery } from 'convex-svelte';
3+ import { api } from '../../../convex/_generated/api';
4+ import type { Id } from '../../../convex/_generated/dataModel';
5+ import XpWindow from './xp-window.svelte';
6+ import { desktop } from '$lib/states/desktop.svelte';
7+ import { chatState } from '$lib/states/chat.svelte';
8+ import { useAimClient } from '$lib/aim/client';
9+ import { STATUS_LABELS_FR } from '$lib/types/aim';
10+ import { formatFrenchDateTime } from '$lib/utils/date-format';
11+
12+ let { userId, nickname } = $props<{ userId: Id<'users'>; nickname: string }>();
13+ const id = $derived(`profile-${userId}`);
14+ const profileQuery = useQuery(api.aim.getUserProfile, () => ({ userId }));
15+ const client = useAimClient();
16+ const profile = $derived(profileQuery.data);
17+ const own = $derived(userId === chatState.getCurrentUser()?.id);
18+ let text = $state('');
19+ let saved = $state(false);
20+ let saving = $state(false);
21+ let error = $state('');
22+ let initializedFor = $state<string | null>(null);
23+ $effect(() => {
24+ if (profile && initializedFor !== userId) {
25+ text = profile.profile ?? '';
26+ initializedFor = userId;
27+ }
28+ });
29+
30+ async function save() {
31+ saving = true;
32+ saved = false;
33+ error = '';
34+ try {
35+ await client.updateProfile({ profile: text });
36+ saved = true;
37+ } catch (err) {
38+ error = err instanceof Error ? err.message : 'Impossible d’enregistrer.';
39+ } finally {
40+ saving = false;
41+ }
42+ }
43+</script>
44+
45+<XpWindow {id} width={380} height={400} minWidth={300} minHeight={260}>
46+ <div class="profile-content">
47+ {#if profile}
48+ <div class="identity">
49+ <img class="buddy-icon" src="/aim/running-man-48.png" alt="" width="48" height="48" />
50+ <div>
51+ <strong>{profile.nickname}</strong>
52+ <div class="status">
53+ <img
54+ src={`/aim/status-${profile.status}.svg`}
55+ alt=""
56+ width="16"
57+ height="16"
58+ />{STATUS_LABELS_FR[profile.status]}
59+ </div>
60+ {#if profile.createdAt}<small
61+ >Membre depuis {formatFrenchDateTime(new Date(profile.createdAt))}</small
62+ >{/if}
63+ </div>
64+ </div>
65+ {#if profile.awayMessage}<div class="away-message">{profile.awayMessage}</div>{/if}
66+ <label for="profile-text">Profil</label>
67+ {#if own}
68+ <textarea
69+ id="profile-text"
70+ class="sunken-panel"
71+ maxlength="1000"
72+ bind:value={text}
73+ oninput={() => (saved = false)}
74+ ></textarea>
75+ <small>{text.length}/1000</small>
76+ {:else}
77+ <div id="profile-text" class="sunken-panel profile-text">
78+ {profile.profile ?? 'Aucun profil pour le moment.'}
79+ </div>
80+ {/if}
81+ {:else if profile === null}
82+ <p>Profil introuvable : {nickname}</p>
83+ {:else}
84+ <p>Chargement du profil…</p>
85+ {/if}
86+ <div class="actions">
87+ {#if error}<span role="alert">{error}</span>{/if}
88+ {#if saved}<span role="status">Enregistré ✓</span>{/if}
89+ {#if own && profile}<button disabled={saving || text.length > 1000} onclick={save}
90+ >Enregistrer</button
91+ >{/if}
92+ <button onclick={() => desktop.close(id)}>Fermer</button>
93+ </div>
94+ </div>
95+</XpWindow>
96+
97+<style>
98+ .profile-content {
99+ display: flex;
100+ flex-direction: column;
101+ gap: 6px;
102+ height: 100%;
103+ overflow: auto;
104+ font:
105+ 11px Tahoma,
106+ 'Pixelated MS Sans Serif',
107+ sans-serif;
108+ }
109+ .identity {
110+ display: flex;
111+ align-items: center;
112+ gap: 10px;
113+ }
114+ .buddy-icon {
115+ flex: 0 0 48px;
116+ }
117+ .status {
118+ display: flex;
119+ align-items: center;
120+ gap: 4px;
121+ }
122+ .away-message {
123+ border: 1px solid #d0c7a0;
124+ background: #fff9dc;
125+ padding: 6px;
126+ white-space: pre-wrap;
127+ }
128+ .sunken-panel {
129+ background: white;
130+ padding: 5px;
131+ box-sizing: border-box;
132+ width: 100%;
133+ min-height: 65px;
134+ font: inherit;
135+ }
136+ .profile-text {
137+ flex: 1;
138+ overflow: auto;
139+ white-space: pre-wrap;
140+ overflow-wrap: anywhere;
141+ }
142+ textarea {
143+ resize: vertical;
144+ }
145+ .actions {
146+ margin-top: auto;
147+ display: flex;
148+ justify-content: flex-end;
149+ align-items: center;
150+ gap: 6px;
151+ }
152+ .actions span[role='alert'] {
153+ color: #a00;
154+ }
155+</style>
A src/lib/components/desktop/sign-on-window.svelte +532 -0
@@ -0,0 +1,532 @@
1+<script lang="ts">
2+ /**
3+ * AIM "Sign On" window: screen name + password, "Save password" checkbox,
4+ * and the classic three-step connection sequence (modem, chime).
5+ * Also hosts the sign-up tab.
6+ */
7+ import { invalidate } from '$app/navigation';
8+ import { z } from 'zod';
9+ import XpWindow from './xp-window.svelte';
10+ import PasswordStrengthIndicator from '../password-strength-indicator.svelte';
11+ import { desktop } from '$lib/states/desktop.svelte';
12+ import { chatState } from '$lib/states/chat.svelte';
13+ import { createSafeUser } from '$lib/types/chat';
14+ import { playSound, unlockAudio, SOUND_DURATIONS_MS } from '$lib/aim/sounds.svelte';
15+ import {
16+ loginSchema,
17+ createRegistrationSchema,
18+ DEFAULT_PASSWORD_CONSTRAINTS
19+ } from '$lib/validation/password';
20+ import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
21+
22+ let { tab = 'signin' } = $props<{ tab?: 'signin' | 'signup' }>();
23+
24+ const WINDOW_ID = 'login';
25+ const REMEMBER_KEY = 'pdraim-screen-name';
26+
27+ // svelte-ignore state_referenced_locally
28+ let activeTab = $state<'signin' | 'signup'>(tab);
29+
30+ // Sign in
31+ let username = $state('');
32+ let password = $state('');
33+ let rememberName = $state(true);
34+ let error = $state('');
35+
36+ // Sign-on sequence: 0 idle, 1 connecting, 2 verifying, 3 starting
37+ let step = $state(0);
38+ let signedOnAs = $state<string | null>(null);
39+
40+ // Sign up
41+ let suUsername = $state('');
42+ let suPassword = $state('');
43+ let suConfirmPassword = $state('');
44+ let captchaAnswer = $state('');
45+ let signupError = $state('');
46+ let signupBusy = $state(false);
47+ let showStrength = $state(false);
48+
49+ $effect(() => {
50+ const saved = localStorage.getItem(REMEMBER_KEY);
51+ if (saved && !username) username = saved;
52+ });
53+
54+ const STEP_LABELS = [
55+ '',
56+ 'Connexion en cours…',
57+ 'Vérification du nom et du mot de passe…',
58+ 'Démarrage des services…'
59+ ];
60+
61+ // Aborted by "Annuler": stops the login request and every later step of the sequence.
62+ let signOnAbort: AbortController | null = null;
63+
64+ function wait(ms: number, signal: AbortSignal) {
65+ return new Promise<void>((resolve, reject) => {
66+ const timer = setTimeout(resolve, ms);
67+ signal.addEventListener(
68+ 'abort',
69+ () => {
70+ clearTimeout(timer);
71+ reject(signal.reason);
72+ },
73+ { once: true }
74+ );
75+ });
76+ }
77+
78+ async function login(
79+ name: string,
80+ pass: string,
81+ signal: AbortSignal
82+ ): Promise<{ ok: boolean; error?: string }> {
83+ const res = await fetch('/api/session/login', {
84+ method: 'POST',
85+ headers: { 'Content-Type': 'application/json' },
86+ body: JSON.stringify({ username: name, password: pass }),
87+ signal
88+ });
89+ const data = await res.json();
90+ if (!res.ok) return { ok: false, error: data.error || 'Connexion impossible.' };
91+ signal.throwIfAborted();
92+ chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
93+ await invalidate('app:session');
94+ await invalidate('app:chat');
95+ return { ok: true };
96+ }
97+
98+ async function runSignOn(name: string, pass: string) {
99+ signOnAbort?.abort();
100+ const controller = new AbortController();
101+ signOnAbort = controller;
102+ const { signal } = controller;
103+ unlockAudio();
104+ error = '';
105+ step = 1;
106+ desktop.signingOn = true;
107+ playSound('modem');
108+ const modemStart = Date.now();
109+ try {
110+ await wait(900, signal);
111+ step = 2;
112+ const result = await login(name, pass, signal);
113+ if (!result.ok) {
114+ error = result.error ?? 'Connexion impossible.';
115+ step = 0;
116+ return;
117+ }
118+ if (rememberName) localStorage.setItem(REMEMBER_KEY, name);
119+ else localStorage.removeItem(REMEMBER_KEY);
120+ step = 3;
121+ // Let the modem "handshake" finish before the welcome chime
122+ const remaining = SOUND_DURATIONS_MS.modem - (Date.now() - modemStart);
123+ await wait(Math.max(400, Math.min(remaining, 2500)), signal);
124+ playSound('welcome');
125+ signedOnAs = name;
126+ desktop.openBuddyList();
127+ desktop.focus(WINDOW_ID);
128+ await wait(1200, signal);
129+ desktop.close(WINDOW_ID);
130+ } catch (err) {
131+ if (!signal.aborted) throw err;
132+ // Cancelled after the credentials were sent: the server may have created a
133+ // session, so honour "Annuler" by signing out (a no-op without a session).
134+ if (step >= 2 && signOnAbort === controller) {
135+ const loggedOut = await fetch('/api/session/logout', { method: 'POST' })
136+ .then((res) => res.ok)
137+ .catch(() => false);
138+ // Re-sync client auth with the server either way; only claim success if the logout worked.
139+ await invalidate('app:session');
140+ if (!loggedOut) error = 'Annulation impossible : tu es peut-être connecté.';
141+ }
142+ } finally {
143+ if (signOnAbort === controller) {
144+ signOnAbort = null;
145+ desktop.signingOn = false;
146+ if (signal.aborted) {
147+ step = 0;
148+ signedOnAs = null;
149+ }
150+ }
151+ }
152+ }
153+
154+ async function handleSignin(e: Event) {
155+ e.preventDefault();
156+ const parsed = loginSchema.safeParse({ username: username.trim(), password: password.trim() });
157+ if (!parsed.success) {
158+ error = parsed.error.issues[0]?.message ?? 'Saisie invalide';
159+ return;
160+ }
161+ try {
162+ await runSignOn(username.trim(), password.trim());
163+ } catch (err) {
164+ console.debug('Sign-on error', err);
165+ error = 'Erreur de connexion. Réessaie.';
166+ step = 0;
167+ }
168+ }
169+
170+ async function handleSignup(e: Event) {
171+ e.preventDefault();
172+ signupError = '';
173+ const schema = createRegistrationSchema(DEFAULT_PASSWORD_CONSTRAINTS);
174+ try {
175+ schema.parse({
176+ suUsername: suUsername.trim(),
177+ suPassword: suPassword.trim(),
178+ suConfirmPassword: suConfirmPassword.trim(),
179+ captchaAnswer: captchaAnswer.trim(),
180+ turnstileToken: ''
181+ });
182+ } catch (err) {
183+ signupError =
184+ err instanceof z.ZodError
185+ ? (err.issues[0]?.message ?? 'Saisie invalide')
186+ : 'Saisie invalide';
187+ return;
188+ }
189+ signupBusy = true;
190+ try {
191+ const res = await fetch('/api/register', {
192+ method: 'POST',
193+ headers: { 'Content-Type': 'application/json' },
194+ body: JSON.stringify({ suUsername, suPassword, suConfirmPassword, captchaAnswer })
195+ });
196+ const data = (await res.json()) as RegisterResponse;
197+ if (!res.ok) {
198+ signupError = (data as RegisterResponseError).error || 'Inscription impossible.';
199+ return;
200+ }
201+ username = suUsername.trim();
202+ password = suPassword.trim();
203+ activeTab = 'signin';
204+ await runSignOn(username, password);
205+ } catch (err) {
206+ console.debug('Signup error', err);
207+ signupError = "Erreur d'inscription. Réessaie.";
208+ } finally {
209+ signupBusy = false;
210+ }
211+ }
212+
213+ function cancelSignOn() {
214+ // runSignOn's catch/finally resets the sequence state
215+ signOnAbort?.abort();
216+ }
217+</script>
218+
219+<XpWindow
220+ id={WINDOW_ID}
221+ width={300}
222+ height={activeTab === 'signin' ? 380 : 600}
223+ resizableWindow={false}
224+>
225+ <div class="signon">
226+ <div class="banner">
227+ <img src="/aim/running-man-48.png" alt="" width="44" height="44" />
228+ <div class="brand">
229+ <div class="brand-aol">
230+ <img src="/aim/aol-logo-16.png" alt="" width="14" height="14" /> PDR
231+ </div>
232+ <div class="brand-title">Instant</div>
233+ <div class="brand-title">Messenger</div>
234+ </div>
235+ </div>
236+
237+ {#if signedOnAs}
238+ <div class="progress">
239+ <p class="welcome">Bienvenue, <b>{signedOnAs}</b> !</p>
240+ <p class="hint">Ouverture de la liste de contacts…</p>
241+ </div>
242+ {:else if step > 0}
243+ <div class="progress">
244+ <p class="step-title">Connexion à PDR AIM</p>
245+ <ol class="steps">
246+ {#each [1, 2, 3] as n (n)}
247+ <li class:done={step > n} class:active={step === n}>{STEP_LABELS[n]}</li>
248+ {/each}
249+ </ol>
250+ <div class="bar"><div class="bar-fill" style="width: {step * 33}%"></div></div>
251+ <button type="button" onclick={cancelSignOn}>Annuler</button>
252+ </div>
253+ {:else}
254+ <div class="tabs">
255+ <button
256+ type="button"
257+ class:active={activeTab === 'signin'}
258+ onclick={() => (activeTab = 'signin')}>Connexion</button
259+ >
260+ <button
261+ type="button"
262+ class:active={activeTab === 'signup'}
263+ onclick={() => (activeTab = 'signup')}>Inscription</button
264+ >
265+ </div>
266+
267+ {#if activeTab === 'signin'}
268+ <form onsubmit={handleSignin}>
269+ <div class="field-row-stacked">
270+ <label for="so-name">Pseudo</label>
271+ <input id="so-name" type="text" bind:value={username} autocomplete="username" />
272+ </div>
273+ <div class="field-row-stacked">
274+ <label for="so-pass">Mot de passe</label>
275+ <input
276+ id="so-pass"
277+ type="password"
278+ bind:value={password}
279+ autocomplete="current-password"
280+ />
281+ </div>
282+ <div class="field-row">
283+ <input id="so-remember" type="checkbox" bind:checked={rememberName} />
284+ <label for="so-remember">Se souvenir du pseudo</label>
285+ </div>
286+ {#if error}
287+ <div class="error">{error}</div>
288+ {/if}
289+ <div class="actions">
290+ <button type="submit" class="primary">Se connecter</button>
291+ </div>
292+ <p class="hint">
293+ Pas encore de compte ?
294+ <button type="button" class="link" onclick={() => (activeTab = 'signup')}
295+ >Inscris-toi</button
296+ >
297+ </p>
298+ </form>
299+ {:else}
300+ <form onsubmit={handleSignup}>
301+ <div class="field-row-stacked">
302+ <label for="su-name">Pseudo</label>
303+ <input id="su-name" type="text" bind:value={suUsername} autocomplete="username" />
304+ </div>
305+ <div class="field-row-stacked">
306+ <label for="su-pass">Mot de passe</label>
307+ <input
308+ id="su-pass"
309+ type="password"
310+ bind:value={suPassword}
311+ autocomplete="new-password"
312+ onfocus={() => (showStrength = true)}
313+ />
314+ {#if showStrength}
315+ <PasswordStrengthIndicator password={suPassword} showDetails={false} />
316+ {/if}
317+ </div>
318+ <div class="field-row-stacked">
319+ <label for="su-confirm">Confirmer le mot de passe</label>
320+ <input
321+ id="su-confirm"
322+ type="password"
323+ bind:value={suConfirmPassword}
324+ autocomplete="new-password"
325+ />
326+ </div>
327+ <div class="field-row-stacked">
328+ <label for="su-captcha">Que signifie PDR ?</label>
329+ <input id="su-captcha" type="text" bind:value={captchaAnswer} />
330+ </div>
331+ {#if signupError}
332+ <div class="error">{signupError}</div>
333+ {/if}
334+ <div class="actions">
335+ <button type="submit" class="primary" disabled={signupBusy}>
336+ {signupBusy ? 'Inscription…' : "S'inscrire"}
337+ </button>
338+ </div>
339+ </form>
340+ {/if}
341+ {/if}
342+ </div>
343+</XpWindow>
344+
345+<style>
346+ .signon {
347+ display: flex;
348+ flex-direction: column;
349+ gap: 8px;
350+ font-size: 11px;
351+ height: 100%;
352+ overflow: hidden;
353+ }
354+
355+ .banner {
356+ display: flex;
357+ align-items: center;
358+ gap: 12px;
359+ padding: 10px 12px;
360+ background: #1b2a9a;
361+ border: 1px solid #0f1a66;
362+ color: #fff;
363+ }
364+
365+ .brand-aol {
366+ display: flex;
367+ align-items: center;
368+ gap: 4px;
369+ font-weight: bold;
370+ font-size: 12px;
371+ letter-spacing: 1px;
372+ }
373+
374+ .brand-title {
375+ font-style: italic;
376+ font-size: 17px;
377+ line-height: 1;
378+ color: #fff;
379+ }
380+
381+ .tabs {
382+ display: flex;
383+ border-bottom: 1px solid #919b9c;
384+ }
385+
386+ .tabs button {
387+ flex: 1;
388+ border: 1px solid #919b9c;
389+ border-bottom: none;
390+ background: #ece9d8;
391+ box-shadow: none;
392+ border-radius: 3px 3px 0 0;
393+ min-width: 0;
394+ padding: 4px;
395+ font-size: 11px;
396+ }
397+
398+ .tabs button.active {
399+ background: #fff;
400+ font-weight: bold;
401+ position: relative;
402+ top: 1px;
403+ }
404+
405+ form {
406+ display: flex;
407+ flex-direction: column;
408+ gap: 8px;
409+ padding: 4px 2px;
410+ }
411+
412+ .field-row-stacked input[type='text'],
413+ .field-row-stacked input[type='password'] {
414+ width: 100%;
415+ }
416+
417+ .actions {
418+ display: flex;
419+ justify-content: center;
420+ margin-top: 4px;
421+ }
422+
423+ .primary {
424+ font-weight: bold;
425+ min-width: 120px;
426+ }
427+
428+ .error {
429+ color: #c00000;
430+ background: #fff0f0;
431+ border: 1px solid #e0a0a0;
432+ padding: 4px 6px;
433+ }
434+
435+ .hint {
436+ color: #555;
437+ text-align: center;
438+ margin: 4px 0 0;
439+ }
440+
441+ .link {
442+ background: none;
443+ border: none;
444+ box-shadow: none;
445+ color: #0000c0;
446+ text-decoration: underline;
447+ padding: 0;
448+ min-width: 0;
449+ font-size: 11px;
450+ cursor: pointer;
451+ }
452+
453+ .progress {
454+ display: flex;
455+ flex-direction: column;
456+ gap: 10px;
457+ padding: 10px 6px;
458+ align-items: center;
459+ }
460+
461+ .step-title {
462+ font-weight: bold;
463+ margin: 0;
464+ }
465+
466+ .steps {
467+ list-style: none;
468+ padding: 0;
469+ margin: 0;
470+ width: 100%;
471+ display: flex;
472+ flex-direction: column;
473+ gap: 4px;
474+ }
475+
476+ .steps li {
477+ color: #888;
478+ padding-left: 18px;
479+ position: relative;
480+ }
481+
482+ .steps li::before {
483+ content: '○';
484+ position: absolute;
485+ left: 2px;
486+ }
487+
488+ .steps li.active {
489+ color: #000;
490+ font-weight: bold;
491+ }
492+
493+ .steps li.active::before {
494+ content: '●';
495+ color: #f6c700;
496+ animation: blink 0.8s steps(2, start) infinite;
497+ }
498+
499+ .steps li.done {
500+ color: #2a7a2a;
501+ }
502+
503+ .steps li.done::before {
504+ content: '✓';
505+ color: #2a7a2a;
506+ }
507+
508+ .bar {
509+ width: 100%;
510+ height: 14px;
511+ border: 1px solid #919b9c;
512+ background: #fff;
513+ padding: 1px;
514+ }
515+
516+ .bar-fill {
517+ height: 100%;
518+ background: repeating-linear-gradient(90deg, #2c6ed5 0 8px, transparent 8px 10px);
519+ transition: width 0.4s ease;
520+ }
521+
522+ .welcome {
523+ font-size: 14px;
524+ margin: 8px 0 0;
525+ }
526+
527+ @keyframes blink {
528+ to {
529+ visibility: hidden;
530+ }
531+ }
532+</style>
A src/lib/components/desktop/taskbar.svelte +318 -0
@@ -0,0 +1,318 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { desktop } from '$lib/states/desktop.svelte';
4+ import { chatState } from '$lib/states/chat.svelte';
5+ import { soundsEnabled, setSoundsEnabled, unlockAudio } from '$lib/aim/sounds.svelte';
6+
7+ let menuOpen = $state(false);
8+ const soundOn = $derived(soundsEnabled());
9+ let clock = $state('');
10+ let menuElement = $state<HTMLElement>();
11+ let startElement = $state<HTMLButtonElement>();
12+ const user = $derived(chatState.getCurrentUser());
13+
14+ function updateClock() {
15+ clock = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
16+ }
17+
18+ onMount(() => {
19+ updateClock();
20+ const timer = setInterval(updateClock, 30_000);
21+ function outside(event: PointerEvent) {
22+ if (
23+ menuOpen &&
24+ !menuElement?.contains(event.target as Node) &&
25+ !startElement?.contains(event.target as Node)
26+ )
27+ menuOpen = false;
28+ }
29+ function escape(event: KeyboardEvent) {
30+ if (event.key === 'Escape') menuOpen = false;
31+ }
32+ document.addEventListener('pointerdown', outside);
33+ document.addEventListener('keydown', escape);
34+ return () => {
35+ clearInterval(timer);
36+ document.removeEventListener('pointerdown', outside);
37+ document.removeEventListener('keydown', escape);
38+ };
39+ });
40+
41+ function choose(action: () => void) {
42+ menuOpen = false;
43+ action();
44+ }
45+
46+ async function logout() {
47+ const response = await fetch('/api/session/logout', { method: 'POST' });
48+ if (response.ok) window.location.reload();
49+ }
50+</script>
51+
52+<div class="taskbar">
53+ <button
54+ class="start"
55+ bind:this={startElement}
56+ aria-expanded={menuOpen}
57+ aria-controls="start-menu"
58+ onclick={() => (menuOpen = !menuOpen)}><span class="start-label">démarrer</span></button
59+ >
60+ {#if menuOpen}
61+ <div class="start-menu" id="start-menu" bind:this={menuElement}>
62+ <div class="menu-header">{user?.nickname ?? 'Invité'}</div>
63+ <button onclick={() => choose(() => desktop.openBuddyList())}
64+ >PDR AIM — Liste de contacts</button
65+ >
66+ <button onclick={() => choose(() => desktop.openChatRoom(null, 'General'))}
67+ >Salon General</button
68+ >
69+ <button onclick={() => choose(() => desktop.openNewRoom())}>Nouveau salon…</button>
70+ <hr />
71+ <button onclick={() => choose(() => desktop.openPreferences())}>Préférences</button>
72+ <button onclick={() => choose(() => desktop.openAbout())}>À propos</button>
73+ <hr />
74+ {#if user}
75+ <button
76+ onclick={() => {
77+ menuOpen = false;
78+ void logout();
79+ }}>Se déconnecter</button
80+ >
81+ {:else}
82+ <button onclick={() => choose(() => desktop.openLogin('signin'))}>Se connecter</button>
83+ <button onclick={() => choose(() => desktop.openLogin('signup'))}>S'inscrire</button>
84+ {/if}
85+ </div>
86+ {/if}
87+ <div class="tasks">
88+ {#each desktop.taskbarWindows as w (w.id)}
89+ <button
90+ class="task"
91+ class:pressed={desktop.focusedId === w.id}
92+ class:minimized={w.minimized}
93+ class:attention={w.attention}
94+ title={w.title}
95+ onclick={() => desktop.toggleFromTaskbar(w.id)}
96+ ><img src={w.icon} alt="" width="16" height="16" /><span>{w.title}</span></button
97+ >
98+ {/each}
99+ </div>
100+ <div class="tray">
101+ <button
102+ class="tray-button"
103+ title={soundOn ? 'Désactiver les sons' : 'Activer les sons'}
104+ aria-label={soundOn ? 'Désactiver les sons' : 'Activer les sons'}
105+ onclick={() => {
106+ unlockAudio();
107+ setSoundsEnabled(!soundOn);
108+ }}
109+ ><img
110+ src={soundOn ? '/aim/xp-volume-16.png' : '/aim/xp-volume-muted-16.png'}
111+ alt=""
112+ width="16"
113+ height="16"
114+ class:muted={!soundOn}
115+ /></button
116+ >
117+ <img
118+ src="/aim/running-man-16.png"
119+ alt=""
120+ title={user ? `Statut : ${user.status}` : 'Hors ligne'}
121+ width="16"
122+ height="16"
123+ />
124+ <time>{clock}</time>
125+ </div>
126+</div>
127+
128+<style>
129+ .taskbar {
130+ position: fixed;
131+ bottom: 0;
132+ left: 0;
133+ right: 0;
134+ height: 30px;
135+ z-index: 5000;
136+ display: flex;
137+ align-items: stretch;
138+ gap: 4px;
139+ background: linear-gradient(
140+ to bottom,
141+ #1f2f86 0,
142+ #3165c4 3%,
143+ #3682e5 6%,
144+ #4490e6 10%,
145+ #3883e5 12%,
146+ #2b71e0 15%,
147+ #2663da 18%,
148+ #235bd6 20%,
149+ #2258d5 23%,
150+ #2157d6 38%,
151+ #245ddb 54%,
152+ #2562df 86%,
153+ #245fdc 89%,
154+ #2158d4 92%,
155+ #1d4ec0 95%,
156+ #1941a5 98%
157+ );
158+ border-top: 0;
159+ box-sizing: border-box;
160+ color: white;
161+ font:
162+ 11px Tahoma,
163+ 'Pixelated MS Sans Serif',
164+ sans-serif;
165+ }
166+ /* Real XP "start" button: 3-slice of the original bitmap (logo | stretch | rounded cap) */
167+ .start {
168+ display: flex;
169+ align-items: center;
170+ border: 0;
171+ border-radius: 0;
172+ height: 30px;
173+ padding: 0 17px 0 34px;
174+ margin: 0;
175+ background:
176+ url('/desktop/start-left.png') left / auto 100% no-repeat,
177+ url('/desktop/start-right.png') right / auto 100% no-repeat,
178+ url('/desktop/start-mid.png') 34px 0 / calc(100% - 51px) 100% no-repeat;
179+ color: white;
180+ font:
181+ bold italic 15px 'Franklin Gothic Medium',
182+ 'Trebuchet MS',
183+ Tahoma,
184+ sans-serif;
185+ cursor: pointer;
186+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.6);
187+ }
188+ .start:hover {
189+ filter: brightness(1.08);
190+ }
191+ .start:active,
192+ .start[aria-expanded='true'] {
193+ filter: brightness(0.85);
194+ }
195+ .start-label {
196+ line-height: 1;
197+ padding-bottom: 2px;
198+ }
199+ .start-menu {
200+ position: absolute;
201+ bottom: 30px;
202+ left: 0;
203+ width: 250px;
204+ background: white;
205+ border: 2px solid #245edb;
206+ box-shadow: 2px 2px 8px #3339;
207+ color: #222;
208+ padding-bottom: 4px;
209+ }
210+ .menu-header {
211+ background: linear-gradient(#245edb, #4f91ef);
212+ color: white;
213+ font-weight: bold;
214+ padding: 10px;
215+ }
216+ .start-menu button {
217+ display: block;
218+ width: 100%;
219+ text-align: left;
220+ background: transparent;
221+ border: 0;
222+ box-shadow: none;
223+ border-radius: 0;
224+ padding: 6px 12px;
225+ font: inherit;
226+ color: #222;
227+ cursor: pointer;
228+ }
229+ .start-menu button:hover,
230+ .start-menu button:focus-visible {
231+ background: #316ac5;
232+ color: white;
233+ }
234+ .start-menu hr {
235+ border: 0;
236+ border-top: 1px solid #ccc;
237+ margin: 4px 8px;
238+ }
239+ .tasks {
240+ display: flex;
241+ gap: 3px;
242+ align-items: center;
243+ flex: 1;
244+ overflow: hidden;
245+ min-width: 0;
246+ }
247+ .task {
248+ display: flex;
249+ align-items: center;
250+ gap: 4px;
251+ width: 160px;
252+ max-width: 160px;
253+ min-width: 36px;
254+ height: 24px;
255+ flex-shrink: 1;
256+ color: white;
257+ background: #ffffff30;
258+ border: 1px solid #ffffff44;
259+ border-radius: 3px;
260+ padding: 2px 6px;
261+ font: inherit;
262+ text-align: left;
263+ cursor: pointer;
264+ }
265+ .task span {
266+ overflow: hidden;
267+ text-overflow: ellipsis;
268+ white-space: nowrap;
269+ }
270+ .task img {
271+ flex: 0 0 16px;
272+ }
273+ .task.pressed {
274+ background: #103b96;
275+ box-shadow: inset 1px 1px 3px #082a76;
276+ }
277+ .task.minimized {
278+ background: #ffffff18;
279+ }
280+ .task.attention {
281+ animation: flash 1s infinite alternate;
282+ }
283+ @keyframes flash {
284+ from {
285+ background: #e69a12;
286+ }
287+ to {
288+ background: #ffdc55;
289+ color: #222;
290+ }
291+ }
292+ .tray {
293+ display: flex;
294+ align-items: center;
295+ gap: 8px;
296+ padding: 0 8px;
297+ background: linear-gradient(#0c91e8, #1684d1);
298+ border-left: 1px solid #78baff;
299+ white-space: nowrap;
300+ }
301+ .tray-button img.muted {
302+ opacity: 0.45;
303+ filter: grayscale(1);
304+ }
305+
306+ .tray-button {
307+ min-width: 0;
308+ padding: 0;
309+ border: 0;
310+ box-shadow: none;
311+ background: none;
312+ cursor: pointer;
313+ font-size: 14px;
314+ }
315+ .tray time {
316+ font: inherit;
317+ }
318+</style>
A src/lib/components/desktop/xp-window.svelte +297 -0
@@ -0,0 +1,297 @@
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
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+ // Follow explicit size changes from the parent (e.g. a dialog switching tabs)
74+ $effect(() => {
75+ if (isMaximized) return;
76+ w = width;
77+ h = height;
78+ });
79+
80+ function close() {
81+ onClose?.();
82+ desktop.close(id);
83+ }
84+
85+ function minimize(e: MouseEvent) {
86+ e.stopPropagation();
87+ desktop.minimize(id);
88+ }
89+
90+ function toggleMaximize(e?: MouseEvent) {
91+ e?.stopPropagation();
92+ if (isMobile) return;
93+ if (isMaximized && restore) {
94+ w = restore.w;
95+ h = restore.h;
96+ left = restore.left;
97+ top = restore.top;
98+ isMaximized = false;
99+ } else {
100+ restore = { w, h, left, top };
101+ left = 4;
102+ top = 4;
103+ w = window.innerWidth - 8;
104+ h = window.innerHeight - 8 - 30; // leave room for the taskbar
105+ isMaximized = true;
106+ }
107+ }
108+</script>
109+
110+{#if win}
111+ <div
112+ class="window xp-window"
113+ class:inactive={!focused}
114+ class:hidden={win.minimized}
115+ class:mobile={isMobile}
116+ style="width: {w}px; height: {h}px; left: {left}px; top: {top}px; z-index: {win.z};"
117+ role="dialog"
118+ aria-label={win.title}
119+ tabindex="-1"
120+ onmousedown={() => desktop.focus(id)}
121+ use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
122+ ondragmove={(e) => {
123+ left = e.detail.x;
124+ top = e.detail.y;
125+ }}
126+ use:resizable={{
127+ enabled: resizableWindow && !isMobile && !isMaximized,
128+ minWidth,
129+ minHeight,
130+ maxWidth: typeof window !== 'undefined' ? window.innerWidth - 16 : 1600,
131+ maxHeight: typeof window !== 'undefined' ? window.innerHeight - 40 : 1200
132+ }}
133+ onresizemove={(e) => {
134+ w = Math.max(minWidth, e.detail.width);
135+ h = Math.max(minHeight, e.detail.height);
136+ }}
137+ >
138+ <div class="title-bar" ondblclick={() => showMaximize && toggleMaximize()} role="presentation">
139+ <div class="title-bar-text">
140+ <img src={win.icon} alt="" width="16" height="16" class="title-icon" />
141+ <span class="title-text">{win.title}</span>
142+ </div>
143+ <div class="title-bar-controls">
144+ <button aria-label="Minimize" onclick={minimize}></button>
145+ {#if showMaximize && !isMobile}
146+ <button aria-label={isMaximized ? 'Restore' : 'Maximize'} onclick={toggleMaximize}
147+ ></button>
148+ {/if}
149+ <button aria-label="Close" onclick={close}></button>
150+ </div>
151+ </div>
152+ {#if menu}
153+ <div class="xp-menubar">{@render menu()}</div>
154+ {/if}
155+ <div class="window-body xp-body {bodyClass}" class:has-menu={Boolean(menu)}>
156+ {@render children()}
157+ </div>
158+ </div>
159+{/if}
160+
161+<style>
162+ .xp-window {
163+ position: fixed;
164+ box-sizing: border-box;
165+ display: flex;
166+ flex-direction: column;
167+ font-family: Tahoma, 'Pixelated MS Sans Serif', Arial, sans-serif;
168+ box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.35);
169+ }
170+
171+ .xp-window.hidden {
172+ display: none;
173+ }
174+
175+ .xp-window.inactive {
176+ box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
177+ }
178+
179+ /* xp.css already styles active/inactive gradients through :not(:focus-within);
180+ force the inactive look based on our own focus tracking instead. */
181+ .xp-window.inactive .title-bar {
182+ background: linear-gradient(
183+ 180deg,
184+ #7697e7 0%,
185+ #7e9ee3 8%,
186+ #94afec 40%,
187+ #97b4e9 88%,
188+ #82a5e4 100%
189+ );
190+ }
191+
192+ .title-bar {
193+ cursor: move;
194+ user-select: none;
195+ display: flex;
196+ align-items: center;
197+ flex: 0 0 auto;
198+ height: 30px;
199+ box-sizing: border-box;
200+ padding: 0 5px 0 4px;
201+ font-size: 13px;
202+ }
203+
204+ .title-bar-text {
205+ display: flex;
206+ align-items: center;
207+ gap: 5px;
208+ white-space: nowrap;
209+ overflow: hidden;
210+ text-overflow: ellipsis;
211+ min-width: 0;
212+ flex: 1;
213+ margin-right: 8px;
214+ }
215+
216+ .title-bar-controls {
217+ display: flex;
218+ align-items: center;
219+ gap: 2px;
220+ flex: 0 0 auto;
221+ }
222+
223+ .title-bar-controls button {
224+ width: 21px;
225+ height: 21px;
226+ min-width: 21px;
227+ min-height: 21px;
228+ margin: 0;
229+ flex: 0 0 21px;
230+ }
231+
232+ .title-text {
233+ overflow: hidden;
234+ text-overflow: ellipsis;
235+ }
236+
237+ .title-icon {
238+ flex: 0 0 16px;
239+ }
240+
241+ .xp-menubar {
242+ display: flex;
243+ gap: 2px;
244+ padding: 1px 4px;
245+ background: #ece9d8;
246+ border-bottom: 1px solid #aca899;
247+ font-size: 11px;
248+ flex: 0 0 auto;
249+ }
250+
251+ .xp-menubar :global(button) {
252+ border: 1px solid transparent;
253+ background: transparent;
254+ box-shadow: none;
255+ min-width: 0;
256+ padding: 2px 6px;
257+ font-size: 11px;
258+ border-radius: 0;
259+ }
260+
261+ .xp-menubar :global(button:hover) {
262+ background: #316ac5;
263+ color: white;
264+ }
265+
266+ .xp-body {
267+ flex: 1 1 auto;
268+ min-height: 0;
269+ display: flex;
270+ flex-direction: column;
271+ margin: 0;
272+ padding: 6px;
273+ overflow: hidden;
274+ }
275+
276+ :global(.xp-window .resize-handle) {
277+ position: absolute;
278+ bottom: 0;
279+ right: 0;
280+ width: 15px !important;
281+ height: 15px !important;
282+ cursor: se-resize !important;
283+ 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");
284+ background-position: bottom right;
285+ background-repeat: no-repeat;
286+ }
287+
288+ @media (max-width: 768px) {
289+ .xp-window.mobile {
290+ left: 0 !important;
291+ top: 0 !important;
292+ width: 100vw !important;
293+ height: calc(100vh - 30px) !important;
294+ border-radius: 0;
295+ }
296+ }
297+</style>
M src/lib/components/formatted-message.svelte +12 -2
@@ -4,6 +4,7 @@
44 import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';
55 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';
66 import { highlightMentionsPlain } from '../utils/mention';
7+ import { replaceSmileys } from '$lib/aim/smileys';
78
89 // Props
910 let { message, allowFormatting = true } = $props<{
@@ -20,7 +21,7 @@
2021 async function processMessage() {
2122 if (!allowFormatting || !message.hasFormatting) {
2223 // Still apply mention highlighting to plain text messages
23- formattedContent = highlightMentionsPlain(escapeHtml(message.content));
24+ formattedContent = replaceSmileys(highlightMentionsPlain(escapeHtml(message.content)));
2425 return;
2526 }
2627
@@ -52,7 +53,9 @@
5253
5354 // Regular formatted text (no gradient)
5455 const styleWithoutGradient = { ...style, gradient: undefined };
55- formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting);
56+ formattedContent = replaceSmileys(
57+ await formatText(message.content, styleWithoutGradient, allowFormatting)
58+ );
5659 } catch (error) {
5760 console.error('Message formatting error:', error);
5861 hasError = true;
@@ -180,6 +183,13 @@
180183 {/if}
181184
182185 <style>
186+ .formatted-message :global(.aim-smiley),
187+ :global(.aim-smiley) {
188+ width: 19px;
189+ height: 19px;
190+ vertical-align: -4px;
191+ image-rendering: pixelated;
192+ }
183193 .processing {
184194 color: #666;
185195 font-style: italic;
M src/lib/components/text-formatting-toolbar.svelte +101 -1
@@ -6,18 +6,24 @@
66 type FontFamily
77 } from '../types/text-formatting';
88 import ColorPicker256 from './color-picker-256.svelte';
9+ import { SMILEYS } from '$lib/aim/smileys';
910
1011 // Props
1112 let {
1213 style = $bindable(DEFAULT_TEXT_STYLE),
1314 compact = true,
14- showFontSelector = false
15+ showFontSelector = false,
16+ onSmiley
1517 } = $props<{
1618 style?: TextStyle;
1719 compact?: boolean;
1820 showFontSelector?: boolean;
21+ /** When set, shows the AIM smiley picker and inserts the chosen code */
22+ onSmiley?: (code: string) => void;
1923 }>();
2024
25+ let smileyOpen = $state(false);
26+
2127 // State
2228 let currentColor = $state(style.color || '#000000');
2329 let currentGradient = $state(style.gradient);
@@ -132,6 +138,38 @@
132138 </button>
133139 </div>
134140
141+ {#if onSmiley}
142+ <div class="smiley-control">
143+ <button
144+ type="button"
145+ title="Smileys"
146+ aria-haspopup="true"
147+ aria-expanded={smileyOpen}
148+ onclick={() => (smileyOpen = !smileyOpen)}
149+ class="smiley-trigger"
150+ >
151+ <img src="/smileys/01.gif" alt="🙂" width="15" height="15" />
152+ </button>
153+ {#if smileyOpen}
154+ <div class="smiley-picker" role="menu">
155+ {#each SMILEYS as smiley (smiley.file)}
156+ <button
157+ type="button"
158+ role="menuitem"
159+ title="{smiley.label} {smiley.codes[0]}"
160+ onclick={() => {
161+ smileyOpen = false;
162+ onSmiley(smiley.codes[0]);
163+ }}
164+ >
165+ <img src="/smileys/{smiley.file}.gif" alt={smiley.emoji} width="19" height="19" />
166+ </button>
167+ {/each}
168+ </div>
169+ {/if}
170+ </div>
171+ {/if}
172+
135173 <!-- Color controls -->
136174 <div class="color-controls">
137175 <ColorPicker256
@@ -177,6 +215,68 @@
177215 gap: 1px;
178216 }
179217
218+ /* xp.css gives every button min-width: 75px; keep toolbar buttons square */
219+ .style-toggles button,
220+ .color-controls :global(.color-picker-container > button) {
221+ width: 22px !important;
222+ height: 20px !important;
223+ min-width: 0;
224+ min-height: 0;
225+ padding: 0 !important;
226+ display: flex;
227+ align-items: center;
228+ justify-content: center;
229+ }
230+
231+ .smiley-control {
232+ position: relative;
233+ display: flex;
234+ }
235+ .smiley-trigger {
236+ width: 24px;
237+ height: 20px;
238+ min-width: 0;
239+ min-height: 0;
240+ padding: 0;
241+ display: flex;
242+ align-items: center;
243+ justify-content: center;
244+ line-height: 0;
245+ }
246+ .smiley-control img {
247+ display: block;
248+ image-rendering: pixelated;
249+ }
250+ .smiley-picker {
251+ position: absolute;
252+ bottom: calc(100% + 2px);
253+ left: 0;
254+ z-index: 60;
255+ display: grid;
256+ grid-template-columns: repeat(4, 25px);
257+ gap: 1px;
258+ padding: 3px;
259+ background: #fff;
260+ border: 1px solid #7f9db9;
261+ box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
262+ }
263+ .smiley-picker button {
264+ width: 25px;
265+ height: 25px;
266+ min-width: 0;
267+ min-height: 0;
268+ padding: 0;
269+ background: transparent;
270+ box-shadow: none;
271+ border: 1px solid transparent;
272+ display: flex;
273+ align-items: center;
274+ justify-content: center;
275+ }
276+ .smiley-picker button:hover {
277+ border-color: #316ac5;
278+ background: #e8eefb;
279+ }
180280 .color-controls {
181281 display: flex;
182282 gap: 1px;
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 {
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 */
A src/lib/states/desktop.svelte.ts +289 -0
@@ -0,0 +1,289 @@
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+const SHOW_TIMESTAMPS_KEY = 'pdraim-show-timestamps';
10+
11+export type WindowKind =
12+ | 'buddy-list'
13+ | 'chat-room'
14+ | 'im'
15+ | 'login'
16+ | 'profile'
17+ | 'away'
18+ | 'new-room'
19+ | 'preferences'
20+ | 'about';
21+
22+export interface DesktopWindow {
23+ id: string;
24+ kind: WindowKind;
25+ title: string;
26+ icon: string;
27+ z: number;
28+ minimized: boolean;
29+ /** Set when a window wants the taskbar button to blink (new IM). */
30+ attention: boolean;
31+ /** Kind-specific payload */
32+ props: Record<string, unknown>;
33+}
34+
35+export interface ImWindowProps {
36+ roomId: Id<'chatRooms'>;
37+ otherUserId: Id<'users'>;
38+ otherNickname: string;
39+}
40+
41+export interface ChatRoomTab {
42+ roomId: Id<'chatRooms'> | null; // null = default room
43+ roomName: string;
44+}
45+
46+export const CHAT_WINDOW_ID = 'chat-rooms';
47+
48+export interface ProfileWindowProps {
49+ userId: Id<'users'>;
50+ nickname: string;
51+}
52+
53+class DesktopState {
54+ windows = $state<DesktopWindow[]>([]);
55+ focusedId = $state<string | null>(null);
56+ private nextZ = 10;
57+
58+ /** Open chat room tabs (all shown in the single chat-room window). */
59+ roomTabs = $state<ChatRoomTab[]>([]);
60+ activeRoomId = $state<Id<'chatRooms'> | null>(null);
61+
62+ /** Whether the login "sign on" sequence is running (modem animation). */
63+ signingOn = $state(false);
64+
65+ /** Preferences: show message times in IM windows. */
66+ showTimestamps = $state(
67+ typeof localStorage === 'undefined' || localStorage.getItem(SHOW_TIMESTAMPS_KEY) !== 'false'
68+ );
69+
70+ setShowTimestamps(value: boolean) {
71+ this.showTimestamps = value;
72+ localStorage.setItem(SHOW_TIMESTAMPS_KEY, String(value));
73+ }
74+
75+ focused = $derived(this.windows.find((w) => w.id === this.focusedId) ?? null);
76+ taskbarWindows = $derived([...this.windows].sort((a, b) => a.id.localeCompare(b.id)));
77+
78+ get(id: string) {
79+ return this.windows.find((w) => w.id === id) ?? null;
80+ }
81+
82+ has(id: string) {
83+ return this.windows.some((w) => w.id === id);
84+ }
85+
86+ /**
87+ * Open (or focus if already open) a window.
88+ * Windows are identified by a stable id so that e.g. an IM with a given
89+ * user is never opened twice.
90+ */
91+ open(win: Omit<DesktopWindow, 'z' | 'minimized' | 'attention'>) {
92+ const existing = this.get(win.id);
93+ if (existing) {
94+ existing.title = win.title;
95+ existing.props = win.props;
96+ this.focus(win.id);
97+ return existing;
98+ }
99+ const created: DesktopWindow = {
100+ ...win,
101+ z: ++this.nextZ,
102+ minimized: false,
103+ attention: false
104+ };
105+ this.windows = [...this.windows, created];
106+ this.focusedId = created.id;
107+ return created;
108+ }
109+
110+ close(id: string) {
111+ if (id === CHAT_WINDOW_ID) {
112+ this.roomTabs = [];
113+ this.activeRoomId = null;
114+ }
115+ this.windows = this.windows.filter((w) => w.id !== id);
116+ if (this.focusedId === id) {
117+ const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
118+ this.focusedId = top?.id ?? null;
119+ }
120+ }
121+
122+ focus(id: string) {
123+ const win = this.get(id);
124+ if (!win) return;
125+ win.minimized = false;
126+ win.attention = false;
127+ win.z = ++this.nextZ;
128+ this.focusedId = id;
129+ }
130+
131+ minimize(id: string) {
132+ const win = this.get(id);
133+ if (!win) return;
134+ win.minimized = true;
135+ if (this.focusedId === id) {
136+ const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
137+ this.focusedId = top?.id ?? null;
138+ }
139+ }
140+
141+ toggleFromTaskbar(id: string) {
142+ const win = this.get(id);
143+ if (!win) return;
144+ if (win.minimized || this.focusedId !== id) {
145+ this.focus(id);
146+ } else {
147+ this.minimize(id);
148+ }
149+ }
150+
151+ requestAttention(id: string) {
152+ const win = this.get(id);
153+ if (win && this.focusedId !== id) win.attention = true;
154+ }
155+
156+ blurAll() {
157+ this.focusedId = null;
158+ }
159+
160+ // ---------- Convenience openers ----------
161+
162+ openBuddyList() {
163+ return this.open({
164+ id: 'buddy-list',
165+ kind: 'buddy-list',
166+ title: 'Liste de contacts',
167+ icon: '/aim/running-man-16.png',
168+ props: {}
169+ });
170+ }
171+
172+ /**
173+ * Chat rooms live as tabs inside one "Salon" window. Opening a room adds
174+ * (or selects) its tab and brings the window forward.
175+ */
176+ openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) {
177+ // General is the default room (tab id null); group names are unique case-insensitively,
178+ // so opening it by concrete id must reuse that tab instead of duplicating it.
179+ if (roomName.toLowerCase() === 'general') {
180+ roomId = null;
181+ roomName = 'General';
182+ }
183+ if (!this.roomTabs.some((t) => t.roomId === roomId)) {
184+ this.roomTabs = [...this.roomTabs, { roomId, roomName }];
185+ }
186+ this.activeRoomId = roomId;
187+ return this.open({
188+ id: CHAT_WINDOW_ID,
189+ kind: 'chat-room',
190+ title: `Salon : ${roomName}`,
191+ icon: '/aim/chat-room-16.png',
192+ props: {}
193+ });
194+ }
195+
196+ selectRoomTab(roomId: Id<'chatRooms'> | null) {
197+ const tab = this.roomTabs.find((t) => t.roomId === roomId);
198+ if (!tab) return;
199+ this.activeRoomId = roomId;
200+ const win = this.get(CHAT_WINDOW_ID);
201+ if (win) win.title = `Salon : ${tab.roomName}`;
202+ }
203+
204+ closeRoomTab(roomId: Id<'chatRooms'> | null) {
205+ const index = this.roomTabs.findIndex((t) => t.roomId === roomId);
206+ if (index === -1) return;
207+ this.roomTabs = this.roomTabs.filter((t) => t.roomId !== roomId);
208+ if (this.roomTabs.length === 0) {
209+ this.close(CHAT_WINDOW_ID);
210+ return;
211+ }
212+ if (this.activeRoomId === roomId) {
213+ const next = this.roomTabs[Math.min(index, this.roomTabs.length - 1)];
214+ this.selectRoomTab(next.roomId);
215+ }
216+ }
217+
218+ openIm(props: ImWindowProps) {
219+ return this.open({
220+ id: `im-${props.roomId}`,
221+ kind: 'im',
222+ title: `${props.otherNickname} - Message instantané`,
223+ icon: '/aim/running-man-16.png',
224+ props: props as unknown as Record<string, unknown>
225+ });
226+ }
227+
228+ openLogin(tab: 'signin' | 'signup' = 'signin') {
229+ return this.open({
230+ id: 'login',
231+ kind: 'login',
232+ title: 'Connexion',
233+ icon: '/aim/running-man-16.png',
234+ props: { tab }
235+ });
236+ }
237+
238+ openProfile(props: ProfileWindowProps) {
239+ return this.open({
240+ id: `profile-${props.userId}`,
241+ kind: 'profile',
242+ title: `Infos sur ${props.nickname}`,
243+ icon: '/aim/xp-info-16.png',
244+ props: props as unknown as Record<string, unknown>
245+ });
246+ }
247+
248+ openAway() {
249+ return this.open({
250+ id: 'away',
251+ kind: 'away',
252+ title: 'Message d’absence',
253+ icon: '/aim/xp-note-16.png',
254+ props: {}
255+ });
256+ }
257+
258+ openNewRoom() {
259+ return this.open({
260+ id: 'new-room',
261+ kind: 'new-room',
262+ title: 'Nouveau salon',
263+ icon: '/aim/chat-room-16.png',
264+ props: {}
265+ });
266+ }
267+
268+ openPreferences() {
269+ return this.open({
270+ id: 'preferences',
271+ kind: 'preferences',
272+ title: 'Préférences',
273+ icon: '/aim/xp-control-panel-16.png',
274+ props: {}
275+ });
276+ }
277+
278+ openAbout() {
279+ return this.open({
280+ id: 'about',
281+ kind: 'about',
282+ title: 'À propos de PDR AIM',
283+ icon: '/aim/xp-help-16.png',
284+ props: {}
285+ });
286+ }
287+}
288+
289+export const desktop = new DesktopState();
A src/lib/types/aim.ts +44 -0
@@ -0,0 +1,44 @@
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+ createdAt?: number;
14+}
15+
16+export interface Buddy extends AimUser {
17+ group: string;
18+ addedAt: number;
19+}
20+
21+export interface DirectRoomSummary {
22+ id: Id<'chatRooms'>;
23+ other: AimUser | null;
24+ lastMessage: { content: string; timestamp: number; senderId: Id<'users'> } | null;
25+ unreadCount: number;
26+}
27+
28+export interface GroupRoomSummary {
29+ id: Id<'chatRooms'>;
30+ name: string;
31+ topic?: string;
32+ createdAt: number;
33+ isDefault: boolean;
34+}
35+
36+export const DEFAULT_BUDDY_GROUPS = ['Buddies', 'Family', 'Co-Workers'] as const;
37+
38+export const STATUS_LABELS_FR: Record<AimStatus, string> = {
39+ online: 'En ligne',
40+ away: 'Absent',
41+ busy: 'Occupé',
42+ idle: 'Inactif',
43+ offline: 'Hors ligne'
44+};
M src/lib/types/chat.ts +1 -0
@@ -8,6 +8,7 @@ export interface User {
88 createdAt: number; // Timestamp (e.g., Unix timestamp)
99 lastSeen?: number | null; // Optional last active timestamp
1010 lastReadMentionTimestamp?: number | null; // Timestamp of last read mention
11+ awayMessage?: string; // AIM away message (only exposed while away)
1112 }
1213
1314 export type UserStatus = 'offline' | 'online' | 'away' | string;
M src/lib/validation/message.ts +3 -0
@@ -3,6 +3,9 @@ import { z } from 'zod';
33 // Message constraints
44 export const MAX_MESSAGE_LENGTH = 500;
55 export const MIN_MESSAGE_LENGTH = 1;
6+// Visitors see this many latest messages of the default room before the registration prompt.
7+// Keep in sync with PUBLIC_MESSAGE_LIMIT in src/convex/queries.ts.
8+export const PUBLIC_PREVIEW_MESSAGES = 50;
69
710 // Message content schema
811 export const messageContentSchema = z
M src/routes/+layout.svelte +6 -1
@@ -196,7 +196,9 @@
196196 $effect(() => {
197197 if (!browser) return;
198198
199- const currentUserJson = data.user ? JSON.stringify(data.user) : null;
199+ const currentUserJson = data.user
200+ ? JSON.stringify({ user: data.user, sessionId: data.session?.id ?? null })
201+ : null;
200202 if (currentUserJson === lastUserUpdate) {
201203 console.debug('Skipping duplicate user update');
202204 return;
@@ -227,6 +229,9 @@
227229 if (data.session?.id) {
228230 console.debug('Updating global chatState with data.user:', data.user);
229231 chatState.setCurrentUser(data.user);
232+ chatState.setSessionTokenHash(data.session.id);
233+ } else {
234+ chatState.setCurrentUser(null);
230235 }
231236 });
232237 </script>
M src/routes/+page.svelte +84 -18
@@ -1,37 +1,103 @@
11 <script lang="ts">
2- import ChatRoom from '$lib/components/chat-room.svelte';
2+ import { onMount } from 'svelte';
3+ import { browser } from '$app/environment';
4+ import ChatRoomsWindow from '$lib/components/desktop/chat-rooms-window.svelte';
35 import DesktopIcons from '$lib/components/desktop-icons.svelte';
4- import AimLogin from '$lib/components/aim-login.svelte';
6+ import Taskbar from '$lib/components/desktop/taskbar.svelte';
7+ import BuddyList from '$lib/components/desktop/buddy-list.svelte';
8+ import ImWindow from '$lib/components/desktop/im-window.svelte';
9+ import SignOnWindow from '$lib/components/desktop/sign-on-window.svelte';
10+ import ProfileWindow from '$lib/components/desktop/profile-window.svelte';
11+ import AwayWindow from '$lib/components/desktop/away-window.svelte';
12+ import NewRoomWindow from '$lib/components/desktop/new-room-window.svelte';
13+ import AboutWindow from '$lib/components/desktop/about-window.svelte';
14+ import PreferencesWindow from '$lib/components/desktop/preferences-window.svelte';
15+ import {
16+ desktop,
17+ CHAT_WINDOW_ID,
18+ type ImWindowProps,
19+ type ProfileWindowProps
20+ } from '$lib/states/desktop.svelte';
21+ import { chatState } from '$lib/states/chat.svelte';
522
623 const props = $props();
724 const pageData = $derived(props.data);
8- let showChatRoom = $state(true);
9- let showAuth = $state(false);
1025
11- function handleLoginSuccess() {
12- showAuth = false;
26+ const currentUser = $derived(chatState.getCurrentUser());
27+
28+ // Initial desktop layout: the General room, plus the buddy list when signed in.
29+ onMount(() => {
30+ if (!browser) return;
31+ desktop.openChatRoom(null, 'General');
32+ if (pageData.user) {
33+ desktop.openBuddyList();
34+ } else {
35+ desktop.openLogin('signin');
36+ }
37+ desktop.focus(CHAT_WINDOW_ID);
38+ });
39+
40+ // When the session appears (login) make sure the buddy list is around;
41+ // when it disappears (logout) close private windows.
42+ let lastUserId: string | null = null;
43+ $effect(() => {
44+ const id = currentUser?.id ?? null;
45+ if (id === lastUserId) return;
46+ lastUserId = id;
47+ if (id) {
48+ if (!desktop.has('buddy-list')) desktop.openBuddyList();
49+ } else {
50+ for (const w of desktop.windows) {
51+ if (w.kind === 'im' || w.kind === 'buddy-list' || w.kind === 'away') desktop.close(w.id);
52+ }
53+ }
54+ });
55+
56+ function onDesktopClick(e: MouseEvent) {
57+ if (e.target === e.currentTarget) desktop.blurAll();
1358 }
1459 </script>
1560
16-<div class="desktop content-wrapper">
17- <DesktopIcons bind:showChatRoom bind:showAuth />
61+<div
62+ class="desktop"
63+ class:signing-on={desktop.signingOn}
64+ onmousedown={onDesktopClick}
65+ role="presentation"
66+>
67+ <DesktopIcons />
1868
19- {#if showChatRoom}
20- <ChatRoom bind:showChatRoom initialTextStyle={pageData.textStyle} />
21- {/if}
69+ {#each desktop.windows as win (win.id)}
70+ {#if win.kind === 'chat-room'}
71+ <ChatRoomsWindow initialTextStyle={pageData.textStyle} />
72+ {:else if win.kind === 'buddy-list'}
73+ <BuddyList />
74+ {:else if win.kind === 'im'}
75+ {@const p = win.props as unknown as ImWindowProps}
76+ <ImWindow roomId={p.roomId} otherUserId={p.otherUserId} otherNickname={p.otherNickname} />
77+ {:else if win.kind === 'login'}
78+ <SignOnWindow tab={(win.props.tab as 'signin' | 'signup') ?? 'signin'} />
79+ {:else if win.kind === 'profile'}
80+ {@const p = win.props as unknown as ProfileWindowProps}
81+ <ProfileWindow userId={p.userId} nickname={p.nickname} />
82+ {:else if win.kind === 'away'}
83+ <AwayWindow />
84+ {:else if win.kind === 'new-room'}
85+ <NewRoomWindow />
86+ {:else if win.kind === 'about'}
87+ <AboutWindow />
88+ {:else if win.kind === 'preferences'}
89+ <PreferencesWindow />
90+ {/if}
91+ {/each}
2292
23- {#if showAuth}
24- <AimLogin bind:showAuth onLoginSuccess={handleLoginSuccess} />
25- {/if}
93+ <Taskbar />
2694 </div>
2795
2896 <style>
29- .content-wrapper {
97+ .desktop {
3098 position: absolute;
3199 inset: 0;
32- display: flex;
33- align-items: center;
34- justify-content: center;
35100 box-sizing: border-box;
101+ overflow: hidden;
36102 }
37103 </style>
M src/routes/api/chat/messages/+server.ts +20 -4
@@ -1,11 +1,11 @@
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';
8-import { sendMessageSchema } from '$lib/validation/message';
8+import { sendMessageSchema, PUBLIC_PREVIEW_MESSAGES } from '$lib/validation/message';
99 import type { RequestHandler } from './$types';
1010
1111 const log = createLogger('chat-server');
@@ -60,6 +60,21 @@ 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+ // Visitors only get a preview of the default room
74+ if (isPublic && roomId !== getDefaultChatRoomId()) {
75+ throw error(403, 'Sign in to read this room');
76+ }
77+
6378 log.debug('Fetching messages from Convex', {
6479 beforeTimestamp,
6580 roomId,
@@ -79,7 +94,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
7994 });
8095 }
8196
82- const fetchLimit = 100;
97+ const fetchLimit = isPublic ? PUBLIC_PREVIEW_MESSAGES : 100;
8398
8499 const result = await messages.getByRoom(
85100 roomId,
@@ -109,6 +124,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
109124 headers: { 'Content-Type': 'application/json' }
110125 });
111126 } catch (err) {
127+ if (isHttpError(err)) throw err;
112128 log.error('Error fetching messages:', { error: err });
113129 throw error(500, 'Failed to fetch messages');
114130 }
A src/routes/api/session/logout/+server.ts +36 -0
@@ -0,0 +1,36 @@
1+import type { RequestHandler } from '@sveltejs/kit';
2+import { invalidateSession } from '$lib/api/session.server';
3+import { deleteSessionTokenCookie } from '$lib/api/session.cookie';
4+import { users } from '$lib/db/convex.server';
5+import { createLogger } from '$lib/utils/logger.server';
6+
7+const log = createLogger('logout-server');
8+
9+const json = (body: object, status: number) =>
10+ new Response(JSON.stringify(body), {
11+ status,
12+ headers: { 'Content-Type': 'application/json' }
13+ });
14+
15+export const POST: RequestHandler = async ({ cookies, locals }) => {
16+ if (locals.session) {
17+ try {
18+ await invalidateSession(locals.session.id);
19+ } catch (err) {
20+ // Keep the cookie: the session is still valid, so the client must not think it is signed out.
21+ log.error('Session invalidation failed', {
22+ error: err instanceof Error ? err.message : 'Unknown error'
23+ });
24+ return json({ success: false }, 500);
25+ }
26+ try {
27+ await users.updateStatus(locals.session.userId, 'offline');
28+ } catch (err) {
29+ log.warn('Offline status update failed on logout', {
30+ error: err instanceof Error ? err.message : 'Unknown error'
31+ });
32+ }
33+ }
34+ deleteSessionTokenCookie({ cookies });
35+ return json({ success: true }, 200);
36+};
A static/aim/add-buddy-16.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/add-buddy-48.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/aim-help.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/aim-setup.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/aim-signon.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/aol-logo-16.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/chat-room-16.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/chat-room-48.png +0 -0 binary file: no text diff

binary file: no text diff

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>
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>
D static/aim/help-icon.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/profile-16.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/profile-48.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/running-man-16.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/running-man-32.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/running-man-48.png +0 -0 binary file: no text diff

binary file: no text diff

D static/aim/setup-icon.png +0 -0 binary file: no text diff

binary file: no text diff

D static/aim/signin-icon.png +0 -0 binary file: no text diff

binary file: no text diff

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>
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>
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>
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>
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>
A static/aim/xp-control-panel-16.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/xp-control-panel-48.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/xp-help-16.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/xp-help-48.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/xp-info-16.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/xp-info-48.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/xp-note-16.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/xp-note-48.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/xp-volume-16.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/xp-volume-48.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/xp-volume-muted-16.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/xp-volume-muted-48.png +0 -0 binary file: no text diff

binary file: no text diff

D static/desktop/aim-desktop-icon.png +0 -0 binary file: no text diff

binary file: no text diff

D static/desktop/application-x-shellscript.png +0 -0 binary file: no text diff

binary file: no text diff

A static/desktop/pdraim-icon.png +0 -0 binary file: no text diff

binary file: no text diff

A static/desktop/start-left.png +0 -0 binary file: no text diff

binary file: no text diff

A static/desktop/start-mid.png +0 -0 binary file: no text diff

binary file: no text diff

A static/desktop/start-right.png +0 -0 binary file: no text diff

binary file: no text diff

A static/smileys/README.md +10 -0
@@ -0,0 +1,10 @@
1+# Smileys AIM (images locales)
2+
3+Ce dossier est ignoré par git (sauf ce fichier) : les smileys jaunes
4+d'AOL Instant Messenger appartiennent à AOL et ne sont pas redistribués.
5+
6+Sans fichier, chaque smiley s'affiche avec son emoji Unicode de secours.
7+Pour les vrais smileys, déposer ici `01.gif` … `16.gif`, tirés du pack
8+« Yellow Smiley » (`0201D201A5`) de l'archive
9+<https://archive.org/details/aol_instant_messenger_smiley_packs>
10+(`smiley_yellow_NN.gif` → `NN.gif`). Codes : `src/lib/aim/smileys.ts`.
A static/sounds/README.md +23 -0
@@ -0,0 +1,23 @@
1+# Sons AIM (échantillons locaux)
2+
3+Ce dossier est ignoré par git (sauf ce fichier) : les sons originaux
4+d'AOL Instant Messenger appartiennent à AOL et ne sont pas redistribués
5+dans le dépôt.
6+
7+Sans fichier, l'application synthétise des sons approchants (Web Audio).
8+Pour retrouver les vrais sons, déposer ici :
9+
10+| Fichier | Son AIM d'origine |
11+| --------------- | ----------------- |
12+| `doorOpen.wav` | BuddyIn.wav |
13+| `doorSlam.wav` | BuddyOut.wav |
14+| `imReceive.wav` | IM.wav |
15+| `imSend.wav` | (optionnel) |
16+| `welcome.wav` | Welcome.wav |
17+| `modem.wav` | (optionnel) |
18+
19+Formats acceptés (dans cet ordre) : `.flac`, `.mp3`, `.wav`, `.ogg`. Pour les gros
20+fichiers, `.flac` est sans perte et bien plus léger :
21+`ffmpeg -i modem.wav -c:a flac -compression_level 12 modem.flac`. Garder le `.wav`
22+à côté : il n'est téléchargé que si le navigateur ne décode pas le FLAC. Source archivée connue :
23+<https://archive.org/details/im_20191103>.

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