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/
30 migration/30 migration/
31 31
32 # Plans32 # 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 @@
15 Cache-Control: public, max-age=259200015 Cache-Control: public, max-age=2592000
16 16
17 /desktop/*17 /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/*
18 Cache-Control: public, max-age=2592000⏎̸25 Cache-Control: public, max-age=2592000⏎̸
M src/convex/_generated/api.d.ts +2 -0
@@ -8,6 +8,7 @@
8 * @module8 * @module
9 */9 */
10 10
11+import type * as aim from "../aim.js";
11 import type * as auth from "../auth.js";12 import type * as auth from "../auth.js";
12 import type * as chatRoomsInternal from "../chatRoomsInternal.js";13 import type * as chatRoomsInternal from "../chatRoomsInternal.js";
13 import type * as crons from "../crons.js";14 import type * as crons from "../crons.js";
@@ -26,6 +27,7 @@ import type {
26 } from "convex/server";27 } from "convex/server";
27 28
28 declare const fullApi: ApiFromModules<{29 declare const fullApi: ApiFromModules<{
30+ aim: typeof aim;
29 auth: typeof auth;31 auth: typeof auth;
30 chatRoomsInternal: typeof chatRoomsInternal;32 chatRoomsInternal: typeof chatRoomsInternal;
31 crons: typeof crons;33 crons: typeof crons;
M src/convex/_generated/server.d.ts +19 -0
@@ -21,6 +21,17 @@ import {
21 } from "convex/server";21 } from "convex/server";
22 import type { DataModel } from "./dataModel.js";22 import type { DataModel } from "./dataModel.js";
23 23
24+/**
25+ * Typesafe environment variables.
26+ *
27+ * This includes platform-provided env vars and any variables declared in
28+ * `convex.config.ts`.
29+ */
30+type Env = {
31+ readonly CONVEX_CLOUD_URL: string;
32+ readonly CONVEX_SITE_URL: string;
33+};
34+
24 /**35 /**
25 * Define a query in this Convex app's public API.36 * Define a query in this Convex app's public API.
26 *37 *
@@ -95,6 +106,14 @@ export declare const internalAction: ActionBuilder<DataModel, "internal">;
95 */106 */
96 export declare const httpAction: HttpActionBuilder;107 export declare const httpAction: HttpActionBuilder;
97 108
109+/**
110+ * Typesafe environment variables.
111+ *
112+ * This includes platform-provided env vars and any variables declared in
113+ * `convex.config.ts`.
114+ */
115+export declare const env: Env;
116+
98 /**117 /**
99 * A set of services for use within Convex query functions.118 * A set of services for use within Convex query functions.
100 *119 *
M src/convex/_generated/server.js +8 -0
@@ -91,3 +91,11 @@ export const internalAction = internalActionGeneric;
91 * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.91 * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
92 */92 */
93 export const httpAction = httpActionGeneric;93 export const httpAction = httpActionGeneric;
94+
95+/**
96+ * Typesafe environment variables.
97+ *
98+ * This includes platform-provided env vars and any variables declared in
99+ * `convex.config.ts`.
100+ */
101+export const env = process.env;
A src/convex/aim.ts +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({
57 throw new Error('User not found');57 throw new Error('User not found');
58 }58 }
59 59
60+ // Direct rooms are restricted to their two members
61+ if (room.type === 'direct' && !(room.memberIds ?? []).includes(args.senderId)) {
62+ throw new Error('Not a member of this conversation');
63+ }
64+
60 const messageId = await ctx.db.insert('messages', {65 const messageId = await ctx.db.insert('messages', {
61 chatRoomId: args.chatRoomId,66 chatRoomId: args.chatRoomId,
62 senderId: args.senderId,67 senderId: args.senderId,
M src/convex/mutations.ts +3 -0
@@ -20,6 +20,9 @@ export const sendMessage = authMutation({
20 if (!room) {20 if (!room) {
21 throw new Error('Chat room not found');21 throw new Error('Chat room not found');
22 }22 }
23+ if (room.type === 'direct' && !(room.memberIds ?? []).includes(user._id)) {
24+ throw new Error('Not a member of this conversation');
25+ }
23 26
24 const messageId = await ctx.db.insert('messages', {27 const messageId = await ctx.db.insert('messages', {
25 chatRoomId: args.chatRoomId,28 chatRoomId: args.chatRoomId,
M src/convex/queries.ts +43 -47
@@ -1,6 +1,8 @@
1 import { query } from './_generated/server';1 import { query } from './_generated/server';
2+import type { QueryCtx } from './_generated/server';
2 import { authQuery } from './auth';3 import { authQuery } from './auth';
3 import { v } from 'convex/values';4 import { v } from 'convex/values';
5+import { toPublicUser, assertRoomAccess } from './aim';
4 6
5 // Timeout threshold for marking users as offline (2 minutes)7 // Timeout threshold for marking users as offline (2 minutes)
6 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;8 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;
@@ -25,9 +27,22 @@ function computeEffectiveStatus(
25 27
26 // ============ PUBLIC QUERIES (no auth required) ============28 // ============ PUBLIC QUERIES (no auth required) ============
27 29
28-// Public query: Get messages for a chat room (real-time subscription)30+// Visitors (not signed in) only see the latest messages of the default room.
29-// Max 100 messages for public/unauthenticated users31+// Keep in sync with PUBLIC_PREVIEW_MESSAGES in src/lib/validation/message.ts.
30-const PUBLIC_MESSAGE_LIMIT = 100;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)
31 46
32 export const getMessagesPublic = query({47 export const getMessagesPublic = query({
33 args: {48 args: {
@@ -35,6 +50,10 @@ export const getMessagesPublic = query({
35 limit: v.optional(v.number())50 limit: v.optional(v.number())
36 },51 },
37 handler: async (ctx, args) => {52 handler: async (ctx, args) => {
53+ // Only the default room is readable without an account
54+ const defaultRoom = await findDefaultRoom(ctx);
55+ if (!defaultRoom || defaultRoom._id !== args.roomId) return [];
56+
38 // Enforce max limit and clamp to a safe positive integer57 // Enforce max limit and clamp to a safe positive integer
39 const rawLimit = args.limit ?? PUBLIC_MESSAGE_LIMIT;58 const rawLimit = args.limit ?? PUBLIC_MESSAGE_LIMIT;
40 const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit)));59 const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit)));
@@ -75,16 +94,20 @@ export const getMessagesPublic = query({
75 }94 }
76 });95 });
77 96
78-// Public query: Get paginated messages for a chat room (older history)97+// Authenticated query: older history of a room (pagination is members-only)
79-export const getMessagesPublicPage = query({98+export const getMessagesPage = authQuery({
80 args: {99 args: {
81 roomId: v.id('chatRooms'),100 roomId: v.id('chatRooms'),
82 limit: v.optional(v.number()),101 limit: v.optional(v.number()),
83 beforeTimestamp: v.optional(v.number())102 beforeTimestamp: v.optional(v.number())
84 },103 },
85 handler: async (ctx, args) => {104 handler: async (ctx, args) => {
105+ const room = await ctx.db.get(args.roomId);
106+ if (!room) return { messages: [], hasMore: false };
107+ await assertRoomAccess(ctx, room, ctx.user._id);
108+
86 const rawLimit = args.limit ?? 50;109 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)));
88 111
89 let query = ctx.db112 let query = ctx.db
90 .query('messages')113 .query('messages')
@@ -122,13 +145,7 @@ export const getUsersPublic = query({
122 const users = await ctx.db.query('users').collect();145 const users = await ctx.db.query('users').collect();
123 146
124 // Return safe user data (no passwords) with computed status147 // Return safe user data (no passwords) with computed status
125- return users.map((user) => ({148+ return users.map(toPublicUser);
126- id: user._id,
127- nickname: user.nickname,
128- status: computeEffectiveStatus(user.status, user.lastSeen),
129- avatarUrl: user.avatarUrl,
130- lastSeen: user.lastSeen
131- }));
132 }149 }
133 });150 });
134 151
@@ -136,30 +153,10 @@ export const getUsersPublic = query({
136 export const getDefaultRoomPublic = query({153 export const getDefaultRoomPublic = query({
137 args: {},154 args: {},
138 handler: async (ctx) => {155 handler: async (ctx) => {
139- const rooms = await ctx.db.query('chatRooms').collect();156+ const room = await findDefaultRoom(ctx);
140- const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group');157+ return room
141-158+ ? { id: room._id, name: room.name, type: room.type, createdAt: room.createdAt }
142- if (defaultRoom) {159+ : null;
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;
163 }160 }
164 });161 });
165 162
@@ -172,7 +169,10 @@ export const getMessages = authQuery({
172 limit: v.optional(v.number())169 limit: v.optional(v.number())
173 },170 },
174 handler: async (ctx, args) => {171 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+
176 const rawLimit = args.limit ?? 100;176 const rawLimit = args.limit ?? 100;
177 const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));177 const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
178 const query = ctx.db178 const query = ctx.db
@@ -219,21 +219,17 @@ export const getUsers = authQuery({
219 const users = await ctx.db.query('users').collect();219 const users = await ctx.db.query('users').collect();
220 220
221 // Return safe user data (no passwords) with computed status221 // Return safe user data (no passwords) with computed status
222- return users.map((user) => ({222+ return users.map(toPublicUser);
223- id: user._id,
224- nickname: user.nickname,
225- status: computeEffectiveStatus(user.status, user.lastSeen),
226- avatarUrl: user.avatarUrl,
227- lastSeen: user.lastSeen
228- }));
229 }223 }
230 });224 });
231 225
232-// Authenticated query: Get chat rooms226+// Authenticated query: Get chat rooms (direct rooms only when the user is a member)
233 export const getChatRooms = authQuery({227 export const getChatRooms = authQuery({
234 args: {},228 args: {},
235 handler: async (ctx) => {229 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+ );
237 return rooms.map((room) => ({233 return rooms.map((room) => ({
238 id: room._id,234 id: room._id,
239 name: room.name,235 name: room.name,
M src/convex/schema.ts +42 -5
@@ -5,11 +5,14 @@ export default defineSchema({
5 users: defineTable({5 users: defineTable({
6 password: v.string(),6 password: v.string(),
7 nickname: v.string(),7 nickname: v.string(),
8- status: v.string(), // 'offline' | 'online' | 'away' | 'busy'8+ status: v.string(), // 'offline' | 'online' | 'away' | 'busy' | 'idle'
9 avatarUrl: v.optional(v.string()),9 avatarUrl: v.optional(v.string()),
10 createdAt: v.number(),10 createdAt: v.number(),
11 lastSeen: v.optional(v.number()),11 lastSeen: v.optional(v.number()),
12- lastReadMentionTimestamp: v.optional(v.number()) // Timestamp of last read mention12+ lastReadMentionTimestamp: v.optional(v.number()), // Timestamp of last read mention
13+ // AIM profile
14+ awayMessage: v.optional(v.string()), // Shown while status === 'away'
15+ profile: v.optional(v.string()) // Free text "Get Info" profile
13 })16 })
14 .index('by_nickname', ['nickname'])17 .index('by_nickname', ['nickname'])
15 .index('by_status', ['status']),18 .index('by_status', ['status']),
@@ -26,14 +29,20 @@ export default defineSchema({
26 chatRooms: defineTable({29 chatRooms: defineTable({
27 name: v.optional(v.string()),30 name: v.optional(v.string()),
28 type: v.string(), // 'direct' | 'group'31 type: v.string(), // 'direct' | 'group'
29- createdAt: v.number()32+ createdAt: v.number(),
30- }),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']),
31 40
32 messages: defineTable({41 messages: defineTable({
33 chatRoomId: v.id('chatRooms'),42 chatRoomId: v.id('chatRooms'),
34 senderId: v.id('users'),43 senderId: v.id('users'),
35 content: v.string(),44 content: v.string(),
36- type: v.string(), // 'chat' | 'emote' | 'system'45+ type: v.string(), // 'chat' | 'emote' | 'system' | 'auto-response'
37 timestamp: v.number(),46 timestamp: v.number(),
38 styleData: v.optional(v.string()),47 styleData: v.optional(v.string()),
39 hasFormatting: v.optional(v.boolean())48 hasFormatting: v.optional(v.boolean())
@@ -41,6 +50,34 @@ export default defineSchema({
41 .index('by_chatRoom', ['chatRoomId'])50 .index('by_chatRoom', ['chatRoomId'])
42 .index('by_timestamp', ['timestamp']),51 .index('by_timestamp', ['timestamp']),
43 52
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+
44 userTextPreferences: defineTable({81 userTextPreferences: defineTable({
45 userId: v.id('users'),82 userId: v.id('users'),
46 defaultStyle: v.object({83 defaultStyle: v.object({
M src/convex/usersInternal.ts +3 -1
@@ -73,8 +73,10 @@ export const updateStatus = internalMutation({
73 throw new Error('User not found');73 throw new Error('User not found');
74 }74 }
75 75
76+ // An away message keeps the user away: presence heartbeats only refresh lastSeen.
77+ const keepAway = user.awayMessage && args.status !== 'offline';
76 await ctx.db.patch(args.id, {78 await ctx.db.patch(args.id, {
77- status: args.status,79+ status: keepAway ? 'away' : args.status,
78 lastSeen: Date.now()80 lastSeen: Date.now()
79 });81 });
80 82
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 @@
9 import { browser } from '$app/environment';9 import { browser } from '$app/environment';
10 import { env } from '$env/dynamic/public';10 import { env } from '$env/dynamic/public';
11 import { api } from '$lib/api/client';11 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';
16 import LoadingButton from './ui/button-loading.svelte';12 import LoadingButton from './ui/button-loading.svelte';
17 import Tooltip from './ui/tooltip.svelte';13 import Tooltip from './ui/tooltip.svelte';
18 import LoadingDots from './ui/loading-dots.svelte';14 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';
20 import FormattedMessage from './formatted-message.svelte';19 import FormattedMessage from './formatted-message.svelte';
21 import TextFormattingToolbar from './text-formatting-toolbar.svelte';20 import TextFormattingToolbar from './text-formatting-toolbar.svelte';
22 import MentionPicker from './mention-picker.svelte';21 import MentionPicker from './mention-picker.svelte';
@@ -31,34 +30,50 @@
31 } from '../utils/mention';30 } from '../utils/mention';
32 import { DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting';31 import { DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting';
33 import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';32 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';
35 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';34 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
36 35
37 // Props36 // Props
38- let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props<{37+ let {
39- showChatRoom?: boolean;38+ roomId: requestedRoomId = null,
39+ initialTextStyle = DEFAULT_TEXT_STYLE,
40+ showUserList = false
41+ } = $props<{
42+ roomId?: Id<'chatRooms'> | null;
40 initialTextStyle?: TextStyle;43 initialTextStyle?: TextStyle;
44+ /** Mobile only: whether the people list is expanded */
45+ showUserList?: boolean;
41 }>();46 }>();
42 47
43 const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL);48 const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL);
49+ const aim = useAimClient();
44 50
45 // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============51 // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============
46 52
47- // Subscribe to default room (to get room ID)53+ // Subscribe to default room (to get room ID) when no explicit room was requested
48- const defaultRoomQuery = isClient ? useQuery(convexApi.queries.getDefaultRoomPublic, {}) : null;54+ // svelte-ignore state_referenced_locally
55+ const defaultRoomQuery =
56+ isClient && !requestedRoomId ? useQuery(convexApi.queries.getDefaultRoomPublic, {}) : null;
49 57
50 // Subscribe to users (buddy list) - real-time updates58 // Subscribe to users (buddy list) - real-time updates
51 const usersQuery = isClient ? useQuery(convexApi.queries.getUsersPublic, {}) : null;59 const usersQuery = isClient ? useQuery(convexApi.queries.getUsersPublic, {}) : null;
52 60
53- // Room ID from Convex query61+ // Room ID: explicit prop or the default room
54- const roomId = $derived(defaultRoomQuery?.data?.id as Id<'chatRooms'> | undefined);62+ const roomId = $derived(
63+ (requestedRoomId ?? defaultRoomQuery?.data?.id) as Id<'chatRooms'> | undefined
64+ );
55 65
56- // Subscribe to messages - only when room ID is available66+ // Subscribe to messages - skipped until the room ID is available.
57- const messagesQuery = $derived(67+ // Members get the full live feed; visitors only the public preview of the default room.
58- roomId && isClient68+ const memberMessagesQuery = authQuery(convexApi.queries.getMessages, () =>
59- ? useQuery(convexApi.queries.getMessagesPublic, () => ({ roomId: roomId! }))69+ roomId ? { roomId } : 'skip'
60- : null
61 );70 );
71+ const publicMessagesQuery = isClient
72+ ? useQuery(convexApi.queries.getMessagesPublic, () =>
73+ roomId && !tokenHash() ? { roomId } : 'skip'
74+ )
75+ : null;
76+ const messagesQuery = $derived(tokenHash() ? memberMessagesQuery : publicMessagesQuery);
62 77
63 const convexClient = isClient ? useConvexClient() : null;78 const convexClient = isClient ? useConvexClient() : null;
64 79
@@ -71,11 +86,19 @@
71 nickname: u.nickname,86 nickname: u.nickname,
72 status: u.status as SafeUser['status'],87 status: u.status as SafeUser['status'],
73 avatarUrl: u.avatarUrl ?? null,88 avatarUrl: u.avatarUrl ?? null,
74- lastSeen: u.lastSeen ?? null89+ lastSeen: u.lastSeen ?? null,
90+ awayMessage: u.awayMessage
75 }))91 }))
76 .sort((a, b) => {92 .sort((a, b) => {
77- const statusOrder: Record<string, number> = { online: 0, away: 1, busy: 2, offline: 3 };93+ const statusOrder: Record<string, number> = {
78- return (statusOrder[a.status] ?? 3) - (statusOrder[b.status] ?? 3);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);
79 });102 });
80 });103 });
81 104
@@ -152,15 +175,51 @@
152 175
153 // ============ WINDOW STATE ============176 // ============ WINDOW STATE ============
154 177
155- let windowWidth = $state(800);
156- let windowHeight = $state(600);
157- let windowX = $state(0);
158- let windowY = $state(0);
159 let isMobile = $state(false);178 let isMobile = $state(false);
160- let showUserList = $state(false);179+
161- let isMaximized = $state(false);180+ // ============ USER LIST INTERACTIONS (AIM-style) ============
162- let isMinimized = $state(false);181+
163- let isCentered = $state(true);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+ }
164 223
165 // ============ INPUT STATE ============224 // ============ INPUT STATE ============
166 225
@@ -206,10 +265,6 @@
206 let rateLimitWarning = $state<string | null>(null);265 let rateLimitWarning = $state<string | null>(null);
207 let isSendingMessage = $state(false);266 let isSendingMessage = $state(false);
208 267
209- // ============ AUTH STATE ============
210-
211- let showAuth = $state(false);
212-
213 // ============ SCROLL STATE ============268 // ============ SCROLL STATE ============
214 269
215 let isLoadingMore = $state(false);270 let isLoadingMore = $state(false);
@@ -223,11 +278,15 @@
223 // Visible messages (limit for non-logged-in users)278 // Visible messages (limit for non-logged-in users)
224 const visibleMessages = $derived.by<EnrichedMessage[]>(() => {279 const visibleMessages = $derived.by<EnrichedMessage[]>(() => {
225 const isLoggedIn = Boolean(currentUser);280 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));
227 });284 });
228 285
229 // Show registration prompt for non-logged-in users286 // 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+ );
231 290
232 // Character counter291 // Character counter
233 const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8);292 const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8);
@@ -241,9 +300,9 @@
241 );300 );
242 301
243 // User counts302 // User counts
244- const totalUsers = $derived(onlineUsers.length);
245 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));303 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
246 const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));304 const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
305+ const selectedUser = $derived(onlineUsers.find((u) => u.id === selectedUserId) ?? null);
247 306
248 const mentionableUsers = $derived(307 const mentionableUsers = $derived(
249 onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id)308 onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id)
@@ -635,8 +694,6 @@
635 pagedMessages = [...pagedMessages, ...newMessages];694 pagedMessages = [...pagedMessages, ...newMessages];
636 }695 }
637 696
638- const debouncedSaveWindowState = debounce(saveWindowState, 300);
639-
640 // Debounced function to update the server with last read mention timestamp697 // Debounced function to update the server with last read mention timestamp
641 const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => {698 const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => {
642 if (!currentUser || !browser) return;699 if (!currentUser || !browser) return;
@@ -815,10 +872,6 @@
815 872
816 // ============ HANDLERS ============873 // ============ HANDLERS ============
817 874
818- function handleClose() {
819- showChatRoom = false;
820- }
821-
822 function shouldHighlightMention(message: EnrichedMessage) {875 function shouldHighlightMention(message: EnrichedMessage) {
823 if (!currentUser) return false;876 if (!currentUser) return false;
824 if (message.senderId === currentUser.id) return false;877 if (message.senderId === currentUser.id) return false;
@@ -909,51 +962,6 @@
909 }962 }
910 }963 }
911 964
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-
957 async function handleScroll(event: Event) {965 async function handleScroll(event: Event) {
958 const target = event.target as HTMLElement;966 const target = event.target as HTMLElement;
959 const { scrollTop } = target;967 const { scrollTop } = target;
@@ -964,18 +972,19 @@
964 hasMoreMessages &&972 hasMoreMessages &&
965 roomId &&973 roomId &&
966 chatArea &&974 chatArea &&
967- currentUser &&
968 convexClient975 convexClient
969 ) {976 ) {
977+ const hash = tokenHash();
978+ if (!hash) return;
970 isLoadingMore = true;979 isLoadingMore = true;
971 const prevScrollHeight = chatArea.scrollHeight;980 const prevScrollHeight = chatArea.scrollHeight;
972 const prevScrollTop = chatArea.scrollTop;981 const prevScrollTop = chatArea.scrollTop;
973 982
974 try {983 try {
975- const fetchLimit = currentUser ? 100 : 50;984+ const data = await convexClient.query(convexApi.queries.getMessagesPage, {
976- const data = await convexClient.query(convexApi.queries.getMessagesPublicPage, {985+ tokenHash: hash,
977 roomId,986 roomId,
978- limit: fetchLimit,987+ limit: 100,
979 beforeTimestamp: oldestMessageTimestamp ?? undefined988 beforeTimestamp: oldestMessageTimestamp ?? undefined
980 });989 });
981 990
@@ -1007,12 +1016,14 @@
1007 }1016 }
1008 }1017 }
1009 1018
1010- function openSignup() {1019+ function insertSmiley(code: string) {
1011- showAuth = true;1020+ const needsSpace = currentMessage.length > 0 && !currentMessage.endsWith(' ');
1021+ currentMessage = `${currentMessage}${needsSpace ? ' ' : ''}${code} `;
1022+ void tick().then(() => messageInput?.focus());
1012 }1023 }
1013 1024
1014- function handleLoginSuccess() {1025+ function openSignup() {
1015- showAuth = false;1026+ desktop.openLogin('signup');
1016 }1027 }
1017 1028
1018 // ============ LIFECYCLE ============1029 // ============ LIFECYCLE ============
@@ -1021,101 +1032,28 @@
1021 if (!browser) return;1032 if (!browser) return;
1022 1033
1023 isMobile = window.innerWidth <= 768;1034 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-
1050 const handleResize = () => {1035 const handleResize = () => {
1051- const wasMobile = isMobile;
1052 isMobile = window.innerWidth <= 768;1036 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- }
1090 };1037 };
1091-
1092- if (isMobile) handleResize();
1093-
1094 window.addEventListener('resize', handleResize);1038 window.addEventListener('resize', handleResize);
1095 1039
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+
1096 return () => {1047 return () => {
1097 window.removeEventListener('resize', handleResize);1048 window.removeEventListener('resize', handleResize);
1049+ window.removeEventListener('click', onGlobalClick);
1050+ window.removeEventListener('keydown', onGlobalKey);
1098 if (cooldownInterval) clearInterval(cooldownInterval);1051 if (cooldownInterval) clearInterval(cooldownInterval);
1099 // Clean up canvas context used for text measurement1052 // Clean up canvas context used for text measurement
1100 mentionMeasureContext = null;1053 mentionMeasureContext = null;
1101 };1054 };
1102 });1055 });
1103 1056
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-
1119 // Scroll selected mention option into view when selection changes1057 // Scroll selected mention option into view when selection changes
1120 $effect(() => {1058 $effect(() => {
1121 if (!mentionOpen || mentionSuggestions.length === 0) return;1059 if (!mentionOpen || mentionSuggestions.length === 0) return;
@@ -1125,196 +1063,123 @@
1125 });1063 });
1126 </script>1064 </script>
1127 1065
1128-{#if showChatRoom}1066+<div class="chat-room">
1129- <div1067+ {#if mentionUnreadCount > 0 || connectionError}
1130- class="chat-window window"1068+ <div class="room-status">
1131- class:minimized={isMinimized}1069+ {#if mentionUnreadCount > 0}
1132- style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;"1070+ <span class="room-mentions"
1133- use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}1071+ >({mentionUnreadCount} mention{mentionUnreadCount > 1 ? 's' : ''})</span
1134- use:resizable={{1072+ >
1135- enabled: !isMobile && !isMaximized && !isMinimized,1073+ {/if}
1136- minWidth: 400,1074+ {#if connectionError}
1137- minHeight: 500,1075+ <span class="connection-error">⚠️ {connectionError}</span>
1138- maxWidth: window.innerWidth - 40,1076+ {/if}
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>
1198 </div>1077 </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}
1199 1090
1200- {#if !isMinimized}
1201 <div1091 <div
1202- class="window-body"1092+ class="sunken-panel chat-area"
1203- style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;"1093+ style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
1094+ onscroll={(e) => handleScroll(e)}
1095+ bind:this={chatArea}
1204 >1096 >
1205- {#if connectionError}1097+ {#if isInitialLoading}
1206- <div class="error-banner">1098+ <div class="initial-loading">
1207- {connectionError}1099+ <LoadingDots text="Chargement des messages" />
1100+ </div>
1101+ {:else if isLoadingMore}
1102+ <div class="loading-messages">
1103+ <LoadingDots text="Chargement" />
1208 </div>1104 </div>
1209 {/if}1105 {/if}
1210-1106+ {#if showRegistrationPrompt}
1211- <div1107+ <div class="registration-prompt">
1212- class="chat-container"1108+ <img src="/aim/xp-info-16.png" alt="" width="16" height="16" />
1213- style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;"1109+ <span>Vous devez être inscrit pour lire l'historique complet de ce salon.</span>
1214- >1110+ <button type="button" onclick={openSignup}>S'inscrire</button>
1215- {#if rateLimitWarning}1111+ </div>
1216- <div class="rate-limit-warning" class:with-progress={cooldownEndTime}>1112+ {/if}
1217- <span>{rateLimitWarning}</span>1113+ {#each visibleMessages as message (message.id)}
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-
1226 <div1114 <div
1227- class="sunken-panel chat-area"1115+ class="message {message.type} text"
1228- style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"1116+ class:mention-highlight={hasMentionHighlight[message.id]}
1229- onscroll={(e) => handleScroll(e)}1117+ data-message-id={message.id}
1230- bind:this={chatArea}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
1231 >1125 >
1232- {#if isInitialLoading}1126+ {#if message.type === 'emote'}
1233- <div class="initial-loading">1127+ <span class="emote-text">
1234- <LoadingDots text="Chargement des messages" />1128+ <Tooltip
1235- </div>1129+ data={{
1236- {:else if isLoadingMore}1130+ text: formatFrenchDateTime(new Date(message.timestamp)),
1237- <div class="loading-messages">1131+ direction: 'left',
1238- <LoadingDots text="Chargement" />1132+ closeDelay: 1000,
1239- </div>1133+ touchBehavior: 'remove',
1240- {/if}1134+ interactive: false
1241- {#if showRegistrationPrompt}1135+ }}
1242- <div class="registration-prompt">1136+ >
1243- <p>1137+ <span class="nickname pointer-events-none">{message.user.nickname}</span>
1244- 👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button>pour lire1138+ </Tooltip>
1245- le reste du chat !1139+ {message.content}
1246- </p>1140+ </span>
1247- </div>1141+ {:else}
1248- {/if}1142+ <Tooltip
1249- {#each visibleMessages as message (message.id)}1143+ data={{
1250- <div1144+ text: formatFrenchDateTime(new Date(message.timestamp)),
1251- class="message {message.type} text"1145+ direction: 'left',
1252- class:mention-highlight={hasMentionHighlight[message.id]}1146+ closeDelay: 1000,
1253- data-message-id={message.id}1147+ touchBehavior: 'remove',
1254- data-mention={hasMentionHighlight[message.id]1148+ interactive: false
1255- ? `Mention de @${currentUser?.nickname ?? ''}`1149+ }}
1256- : undefined}
1257- aria-label={hasMentionHighlight[message.id]
1258- ? `Message mentionnant ${currentUser?.nickname ?? ''}`
1259- : undefined}
1260- use:registerMentionNode
1261 >1150 >
1262- {#if message.type === 'emote'}1151+ <span class="nickname pointer-events-none">{message.user.nickname}:</span>
1263- <span class="emote-text">1152+ </Tooltip>
1264- <Tooltip1153+ <span class="message-content">
1265- data={{1154+ <FormattedMessage {message} allowFormatting={true} />
1266- text: formatFrenchDateTime(new Date(message.timestamp)),1155+ </span>
1267- direction: 'left',1156+ {/if}
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}
1295 </div>1157 </div>
1158+ {/each}
1159+ </div>
1296 1160
1297- <!-- Text Formatting Toolbar -->1161+ <!-- Text Formatting Toolbar -->
1298- {#if currentUser}1162+ {#if currentUser}
1299- <div style="margin-bottom: 0.25rem;">1163+ <div style="margin-bottom: 0.25rem;">
1300- <TextFormattingToolbar1164+ <TextFormattingToolbar
1301- bind:style={currentTextStyle}1165+ bind:style={currentTextStyle}
1302- compact={true}1166+ compact={true}
1303- showFontSelector={true}1167+ showFontSelector={true}
1304- />1168+ onSmiley={insertSmiley}
1305- </div>1169+ />
1306- {/if}1170+ </div>
1171+ {/if}
1307 1172
1308- <div class="field-row input-container" style="margin: 0; position: relative;">1173+ <div class="field-row input-container" style="margin: 0; position: relative;">
1309- {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}1174+ {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}
1310- <div1175+ <div
1311- class="gradient-input-wrapper"1176+ class="gradient-input-wrapper"
1312- style="flex: 1; position: relative; background: white; overflow: hidden;"1177+ style="flex: 1; position: relative; background: white; overflow: hidden;"
1313- >1178+ >
1314- <!-- Gradient text overlay that syncs with input scroll -->1179+ <!-- Gradient text overlay that syncs with input scroll -->
1315- <span1180+ <span
1316- class="gradient-text-overlay retro-font-{currentTextStyle.fontFamily}"1181+ class="gradient-text-overlay retro-font-{currentTextStyle.fontFamily}"
1317- style="1182+ style="
1318 position: absolute;1183 position: absolute;
1319 left: 3px;1184 left: 3px;
1320 top: 50%;1185 top: 50%;
@@ -1323,66 +1188,66 @@
1323 white-space: nowrap;1188 white-space: nowrap;
1324 font-size: {currentTextStyle.fontSize}px;1189 font-size: {currentTextStyle.fontSize}px;
1325 {currentTextStyle.bold1190 {currentTextStyle.bold
1326- ? currentTextStyle.fontFamily === 'tahoma'1191+ ? currentTextStyle.fontFamily === 'tahoma'
1327- ? 'font-weight: 200;'1192+ ? 'font-weight: 200;'
1328- : 'font-weight: 700;'1193+ : 'font-weight: 700;'
1329- : ''}1194+ : ''}
1330 {currentTextStyle.italic ? 'font-style: italic;' : ''}1195 {currentTextStyle.italic ? 'font-style: italic;' : ''}
1331 {currentTextStyle.underline || currentTextStyle.strikethrough1196 {currentTextStyle.underline || currentTextStyle.strikethrough
1332- ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`1197+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1333- : ''}1198+ : ''}
1334 background: linear-gradient(to right, {currentTextStyle.gradient.join(', ')});1199 background: linear-gradient(to right, {currentTextStyle.gradient.join(', ')});
1335 -webkit-background-clip: text;1200 -webkit-background-clip: text;
1336 -webkit-text-fill-color: transparent;1201 -webkit-text-fill-color: transparent;
1337 background-clip: text;1202 background-clip: text;
1338 "1203 "
1339- aria-hidden="true"1204+ aria-hidden="true"
1340- >1205+ >
1341- <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->1206+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1342- {@html formattedInputMessage}1207+ {@html formattedInputMessage}
1343- </span>1208+ </span>
1344- <input1209+ <input
1345- type="text"1210+ type="text"
1346- bind:value={currentMessage}1211+ bind:value={currentMessage}
1347- maxlength={MAX_MESSAGE_LENGTH}1212+ maxlength={MAX_MESSAGE_LENGTH}
1348- class="styled-input retro-font-{currentTextStyle.fontFamily}"1213+ 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.bold1214+ style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
1350- ? currentTextStyle.fontFamily === 'tahoma'1215+ ? currentTextStyle.fontFamily === 'tahoma'
1351- ? 'font-weight: 200;'1216+ ? 'font-weight: 200;'
1352- : 'font-weight: 700;'1217+ : 'font-weight: 700;'
1353- : ''} {currentTextStyle.italic1218+ : ''} {currentTextStyle.italic
1354- ? 'font-style: italic;'1219+ ? 'font-style: italic;'
1355- : ''} {currentTextStyle.underline || currentTextStyle.strikethrough1220+ : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1356- ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`1221+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1357- : ''}"1222+ : ''}"
1358- onkeydown={handleInputKeydown}1223+ onkeydown={handleInputKeydown}
1359- onkeyup={handleInputKeyup}1224+ onkeyup={handleInputKeyup}
1360- oninput={handleInputChange}1225+ oninput={handleInputChange}
1361- onscroll={handleInputScroll}1226+ onscroll={handleInputScroll}
1362- onfocus={handleInputFocus}1227+ onfocus={handleInputFocus}
1363- onclick={handleInputClick}1228+ onclick={handleInputClick}
1364- bind:this={messageInput}1229+ bind:this={messageInput}
1365- placeholder={!currentUser1230+ placeholder={!currentUser
1366- ? 'Inscris-toi pour participer.'1231+ ? 'Inscris-toi pour participer.'
1367- : cooldownEndTime1232+ : cooldownEndTime
1368- ? `Patientez ${cooldownProgress.toFixed(1)}s...`1233+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1369- : 'Écrivez un message...'}1234+ : 'Écrivez un message...'}
1370- disabled={!currentUser || Boolean(cooldownEndTime)}1235+ disabled={!currentUser || Boolean(cooldownEndTime)}
1371- role="combobox"1236+ role="combobox"
1372- aria-autocomplete="list"1237+ aria-autocomplete="list"
1373- aria-controls={mentionOpen ? mentionListBoxId : undefined}1238+ aria-controls={mentionOpen ? mentionListBoxId : undefined}
1374- aria-expanded={mentionOpen}1239+ aria-expanded={mentionOpen}
1375- />1240+ />
1376- </div>1241+ </div>
1377- {:else}1242+ {:else}
1378- <div1243+ <div
1379- class="mention-input-wrapper"1244+ class="mention-input-wrapper"
1380- style="flex: 1; position: relative; background: white; overflow: hidden;"1245+ style="flex: 1; position: relative; background: white; overflow: hidden;"
1381- >1246+ >
1382- <!-- Text overlay with mention highlighting -->1247+ <!-- Text overlay with mention highlighting -->
1383- <span1248+ <span
1384- class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}"1249+ class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}"
1385- style="1250+ style="
1386 position: absolute;1251 position: absolute;
1387 left: 3px;1252 left: 3px;
1388 top: 50%;1253 top: 50%;
@@ -1392,134 +1257,222 @@
1392 font-size: {currentTextStyle.fontSize}px;1257 font-size: {currentTextStyle.fontSize}px;
1393 color: {currentTextStyle.color || '#000000'};1258 color: {currentTextStyle.color || '#000000'};
1394 {currentTextStyle.bold1259 {currentTextStyle.bold
1395- ? currentTextStyle.fontFamily === 'tahoma'1260+ ? currentTextStyle.fontFamily === 'tahoma'
1396- ? 'font-weight: 200;'1261+ ? 'font-weight: 200;'
1397- : 'font-weight: 700;'1262+ : 'font-weight: 700;'
1398- : ''}1263+ : ''}
1399 {currentTextStyle.italic ? 'font-style: italic;' : ''}1264 {currentTextStyle.italic ? 'font-style: italic;' : ''}
1400 {currentTextStyle.underline || currentTextStyle.strikethrough1265 {currentTextStyle.underline || currentTextStyle.strikethrough
1401- ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`1266+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1402- : ''}1267+ : ''}
1403 "1268 "
1404- aria-hidden="true"1269+ aria-hidden="true"
1405- >1270+ >
1406- <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->1271+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1407- {@html formattedInputMessage}1272+ {@html formattedInputMessage}
1408- </span>1273+ </span>
1409- <input1274+ <input
1410- type="text"1275+ type="text"
1411- bind:value={currentMessage}1276+ bind:value={currentMessage}
1412- maxlength={MAX_MESSAGE_LENGTH}1277+ maxlength={MAX_MESSAGE_LENGTH}
1413- class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}"1278+ 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.bold1279+ style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
1415- ? currentTextStyle.fontFamily === 'tahoma'1280+ ? currentTextStyle.fontFamily === 'tahoma'
1416- ? 'font-weight: 200;'1281+ ? 'font-weight: 200;'
1417- : 'font-weight: 700;'1282+ : 'font-weight: 700;'
1418- : ''} {currentTextStyle.italic1283+ : ''} {currentTextStyle.italic
1419- ? 'font-style: italic;'1284+ ? 'font-style: italic;'
1420- : ''} {currentTextStyle.underline || currentTextStyle.strikethrough1285+ : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1421- ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`1286+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1422- : ''} caret-color: {currentTextStyle.color || 'black'};"1287+ : ''} caret-color: {currentTextStyle.color || 'black'};"
1423- onkeydown={handleInputKeydown}1288+ onkeydown={handleInputKeydown}
1424- onkeyup={handleInputKeyup}1289+ onkeyup={handleInputKeyup}
1425- oninput={handleInputChange}1290+ oninput={handleInputChange}
1426- onscroll={handleInputScroll}1291+ onscroll={handleInputScroll}
1427- onfocus={handleInputFocus}1292+ onfocus={handleInputFocus}
1428- onclick={handleInputClick}1293+ onclick={handleInputClick}
1429- bind:this={messageInput}1294+ bind:this={messageInput}
1430- placeholder={!currentUser1295+ placeholder={!currentUser
1431- ? 'Inscris-toi pour participer.'1296+ ? 'Inscris-toi pour participer.'
1432- : cooldownEndTime1297+ : cooldownEndTime
1433- ? `Patientez ${cooldownProgress.toFixed(1)}s...`1298+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1434- : 'Écrivez un message...'}1299+ : 'É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}
1445 disabled={!currentUser || Boolean(cooldownEndTime)}1300 disabled={!currentUser || Boolean(cooldownEndTime)}
1446- loading={isSendingMessage}1301+ role="combobox"
1447- text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'}1302+ aria-autocomplete="list"
1303+ aria-controls={mentionOpen ? mentionListBoxId : undefined}
1304+ aria-expanded={mentionOpen}
1448 />1305 />
1449- {#if showCharCounter}
1450- <span class="char-counter {charCounterClass}">
1451- {currentMessage.length}/{MAX_MESSAGE_LENGTH}
1452- </span>
1453- {/if}
1454 </div>1306 </div>
1455- {#if mentionOpen}1307+ {/if}
1456- <MentionPicker1308+ <LoadingButton
1457- suggestions={mentionSuggestions}1309+ onclick={handleSubmit}
1458- selectedIndex={mentionSelectedIndex}1310+ disabled={!currentUser || Boolean(cooldownEndTime)}
1459- activeId={mentionActiveId}1311+ loading={isSendingMessage}
1460- listBoxId={mentionListBoxId}1312+ text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'}
1461- listStyle={mentionListStyle}1313+ />
1462- isPortal={!mentionListInside}1314+ {#if showCharCounter}
1463- onSelect={applyMentionSelection}1315+ <span class="char-counter {charCounterClass}">
1464- />1316+ {currentMessage.length}/{MAX_MESSAGE_LENGTH}
1465- {/if}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>
1466 1339
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)}
1468 <div1373 <div
1469- class="cooldown-progress"1374+ class="user offline"
1470- style="width: {100 -1375+ class:selected={selectedUserId === user.id}
1471- ((cooldownProgress * 100) / (cooldownEndTime - Date.now())) * 1000}%"1376+ role="option"
1472- ></div>1377+ aria-selected={selectedUserId === user.id}
1473- {/if}1378+ tabindex="0"
1474- </div>1379+ title={statusLabel(user)}
1475-1380+ onclick={(e) => {
1476- <!-- Online users list -->1381+ e.stopPropagation();
1477- <div1382+ selectedUserId = user.id;
1478- class="sunken-panel users-list"1383+ closeUserMenu();
1479- class:mobile={isMobile}1384+ }}
1480- class:hidden={isMobile && !showUserList}1385+ ondblclick={() => openImWith(user)}
1481- style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"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
1482 >1401 >
1483- <!-- Online users section -->1402+ <button
1484- {#if usersOnline.length > 0}1403+ type="button"
1485- <p class="section-header">En ligne ({usersOnline.length})</p>1404+ disabled={!selectedUser}
1486- {#each usersOnline as user (user.id)}1405+ onclick={() =>
1487- <div class="user">{user.nickname}</div>1406+ selectedUser &&
1488- {/each}1407+ desktop.openProfile({
1489- {/if}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>
1490 1421
1491- <!-- Offline users section -->1422+ {#if userMenu}
1492- {#if usersOffline.length > 0}1423+ <div
1493- {#if usersOnline.length > 0}1424+ class="user-menu"
1494- <div class="section-separator"></div>1425+ style="left: {userMenu.x}px; top: {userMenu.y}px;"
1495- {/if}1426+ role="menu"
1496- <p class="section-header offline-header">Hors ligne ({usersOffline.length})</p>1427+ tabindex="-1"
1497- {#each usersOffline as user (user.id)}1428+ onclick={(e) => e.stopPropagation()}
1498- <div class="user offline">1429+ onkeydown={(e) => {
1499- <Tooltip1430+ if (e.key === 'Escape') closeUserMenu();
1500- data={{1431+ }}
1501- text: 'Dernière connexion: ' + formatFrenchRelativeTimeSafe(user.lastSeen),1432+ >
1502- direction: 'bottom',1433+ <div class="user-menu-title">{userMenu.user.nickname}</div>
1503- closeDelay: 1000,1434+ {#if currentUser && userMenu.user.id !== currentUser.id}
1504- touchBehavior: 'remove'1435+ <button
1505- }}1436+ type="button"
1506- >1437+ role="menuitem"
1507- <span class="nickname">{user.nickname}</span>1438+ onclick={() => {
1508- </Tooltip>1439+ const u = userMenu?.user;
1509- </div>1440+ closeUserMenu();
1510- {/each}1441+ if (u) void openImWith(u);
1511- {/if}1442+ }}>Envoyer un message instantané</button
1512- </div>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+ >
1513 </div>1463 </div>
1514 {/if}1464 {/if}
1515 </div>1465 </div>
1516-{/if}1466+</div>
1517-
1518-{#if showAuth}
1519- <AimLogin bind:showAuth activeTab={'signup' as const} onLoginSuccess={handleLoginSuccess} />
1520-{/if}
1521 1467
1522 <style>1468 <style>
1469+ .chat-room {
1470+ flex: 1 1 auto;
1471+ min-height: 0;
1472+ display: flex;
1473+ flex-direction: column;
1474+ }
1475+
1523 .chat-area,1476 .chat-area,
1524 .users-list {1477 .users-list {
1525 font-size: 1rem;1478 font-size: 1rem;
@@ -1531,34 +1484,159 @@
1531 font-family: 'Pixelated MS Sans Serif', Arial, Verdana, Tahoma, sans-serif;1484 font-family: 'Pixelated MS Sans Serif', Arial, Verdana, Tahoma, sans-serif;
1532 }1485 }
1533 1486
1534- .title-bar {1487+ /* ===== Layout inside the XP window ===== */
1535- height: 2rem;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 {
1536 position: relative;1504 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;
1537 cursor: default;1558 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;
1538 user-select: none;1570 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;
1539 display: flex;1614 display: flex;
1540- align-items: center;1615+ flex-direction: column;
1541- padding: 0 0.5rem;
1542 }1616 }
1543 1617
1544- .title-bar:not(:has(button:hover)) {1618+ .user-menu-title {
1545- cursor: move;1619+ font-weight: bold;
1620+ padding: 3px 20px;
1621+ border-bottom: 1px solid #aca899;
1622+ margin-bottom: 2px;
1623+ color: #444;
1546 }1624 }
1547 1625
1548- .chat-window {1626+ .user-menu button {
1549- position: fixed;1627+ text-align: left;
1550- box-sizing: border-box;1628+ background: transparent;
1551- transition:1629+ border: none;
1552- width 0.3s ease,1630+ box-shadow: none;
1553- height 0.3s ease,1631+ border-radius: 0;
1554- left 0.3s ease,1632+ padding: 3px 20px;
1555- top 0.3s ease;1633+ min-width: 0;
1556- will-change: transform;1634+ font-size: 11px;
1557 }1635 }
1558 1636
1559- .chat-window.dragging {1637+ .user-menu button:hover {
1560- transition: none !important;1638+ background: #316ac5;
1561- user-select: none;1639+ color: #fff;
1562 }1640 }
1563 1641
1564 .chat-area {1642 .chat-area {
@@ -1647,42 +1725,6 @@
1647 -webkit-text-fill-color: transparent !important;1725 -webkit-text-fill-color: transparent !important;
1648 }1726 }
1649 1727
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-
1686 .sunken-panel {1728 .sunken-panel {
1687 background: white;1729 background: white;
1688 border: 0.125rem inset #dfdfdf;1730 border: 0.125rem inset #dfdfdf;
@@ -1697,8 +1739,14 @@
1697 font-style: inherit;1739 font-style: inherit;
1698 }1740 }
1699 1741
1700- button {1742+ .input-container :global(> button) {
1701- font-size: 1rem;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;
1702 }1750 }
1703 1751
1704 .chat-container {1752 .chat-container {
@@ -1728,90 +1776,14 @@
1728 display: none !important;1776 display: none !important;
1729 }1777 }
1730 1778
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-
1743 @media (max-width: 768px) {1779 @media (max-width: 768px) {
1744- .window {1780+ .room-panel {
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 {
1791 flex-direction: column;1781 flex-direction: column;
1792 }1782 }
1793 1783
1794- .chat-container {1784+ .people-panel.mobile {
1795- margin-right: 0 !important;1785+ width: 100%;
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;
1806 max-height: 30vh;1786 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;
1815 }1787 }
1816 }1788 }
1817 1789
@@ -1947,59 +1919,31 @@
1947 }1919 }
1948 1920
1949 .registration-prompt {1921 .registration-prompt {
1950- background: #fff3e0;1922+ display: flex;
1951- color: #e65100;1923+ align-items: center;
1952- padding: 1rem;1924+ gap: 8px;
1953- margin-bottom: 1rem;1925+ background: #ece9d8;
1954- border-radius: 4px;1926+ color: #000;
1955- text-align: center;1927+ padding: 6px 8px;
1956- font-size: 0.95rem;1928+ margin-bottom: 6px;
1957- border: 1px solid #ffe0b2;1929+ border: 1px solid #aca899;
1958- animation: fadeIn 0.3s ease-out;1930+ font-size: 0.85rem;
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- }
1974 }1931 }
1975 1932
1976- .registration-prompt .link-button {1933+ .registration-prompt span {
1977- color: #e65100;1934+ flex: 1;
1978- text-decoration: underline;
1979- cursor: pointer;
1980- background: none;
1981- border: none;
1982- padding: 0;
1983- font: inherit;
1984 }1935 }
1985 1936
1986- .registration-prompt .link-button:hover {1937+ .registration-prompt button {
1987- color: #ef6c00;1938+ min-width: 0;
1988- text-decoration: none;1939+ padding: 0 10px;
1940+ white-space: nowrap;
1989 }1941 }
1990 1942
1991 .chat-window.minimized {1943 .chat-window.minimized {
1992 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);1944 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
1993 }1945 }
1994 1946
1995- .minimized .title-bar {
1996- cursor: pointer;
1997- }
1998-
1999- .minimized .window-body {
2000- display: none;
2001- }
2002-
2003 .minimized :global(.resize-handle) {1947 .minimized :global(.resize-handle) {
2004 display: none;1948 display: none;
2005 }1949 }
M src/lib/components/color-picker-256.svelte +30 -9
@@ -16,6 +16,26 @@
16 16
17 // State17 // State
18 let showPicker = $state(false);18 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+ }
19 let gradientColors = $state<string[]>([selectedColor || '#000000']);39 let gradientColors = $state<string[]>([selectedColor || '#000000']);
20 let activeGradientSlot = $state(0);40 let activeGradientSlot = $state(0);
21 41
@@ -123,6 +143,7 @@
123 <button143 <button
124 onclick={(e) => {144 onclick={(e) => {
125 e.stopPropagation();145 e.stopPropagation();
146+ if (!showPicker) placePicker(e.currentTarget as HTMLElement);
126 showPicker = !showPicker;147 showPicker = !showPicker;
127 }}148 }}
128 title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'}149 title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'}
@@ -137,7 +158,11 @@
137 <!-- Color picker dropdown -->158 <!-- Color picker dropdown -->
138 {#if showPicker}159 {#if showPicker}
139 <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->160 <!-- 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+ >
141 <!-- Gradient controls -->166 <!-- Gradient controls -->
142 <div class="gradient-controls">167 <div class="gradient-controls">
143 <div class="gradient-slots">168 <div class="gradient-slots">
@@ -233,12 +258,9 @@
233 }258 }
234 259
235 .picker-dropdown {260 .picker-dropdown {
236- position: absolute;261+ position: fixed;
237- top: 100%;262+ z-index: 6000; /* above windows and the taskbar */
238- right: 0; /* Changed from left: 0 to prevent cutoff */
239- z-index: 1000;
240 padding: 4px;263 padding: 4px;
241- margin-top: 2px;
242 background: #ece9d8; /* Authentic XP dialog background */264 background: #ece9d8; /* Authentic XP dialog background */
243 width: 420px;265 width: 420px;
244 max-height: 380px;266 max-height: 380px;
@@ -251,9 +273,8 @@
251 /* Responsive positioning for mobile/small screens */273 /* Responsive positioning for mobile/small screens */
252 @media (max-width: 768px) {274 @media (max-width: 768px) {
253 .picker-dropdown {275 .picker-dropdown {
254- position: fixed;276+ top: 50% !important;
255- top: 50%;277+ left: 50% !important;
256- left: 50%;
257 transform: translate(-50%, -50%);278 transform: translate(-50%, -50%);
258 right: auto;279 right: auto;
259 width: 90vw;280 width: 90vw;
M src/lib/components/desktop-icons.svelte +101 -52
@@ -1,5 +1,54 @@
1 <script lang="ts">1 <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);
3 52
4 function handleKeydown(event: KeyboardEvent, action: () => void) {53 function handleKeydown(event: KeyboardEvent, action: () => void) {
5 if (event.key === 'Enter' || event.key === ' ') {54 if (event.key === 'Enter' || event.key === ' ') {
@@ -7,89 +56,89 @@
7 action();56 action();
8 }57 }
9 }58 }
10-
11- function handleChatRoomClick() {
12- showChatRoom = true;
13- }
14-
15- function handleAuthClick() {
16- showAuth = true;
17- }
18 </script>59 </script>
19 60
20 <div class="desktop-icons">61 <div class="desktop-icons">
21- <div62+ {#each icons.filter((i) => !i.hidden) as icon (icon.id)}
22- class="desktop-icon"63+ <div
23- ondblclick={handleChatRoomClick}64+ class="desktop-icon"
24- onkeydown={(e) => handleKeydown(e, handleChatRoomClick)}65+ class:selected={selected === icon.id}
25- role="button"66+ onclick={(e) => {
26- tabindex="0"67+ e.stopPropagation();
27- >68+ selected = icon.id;
28- <img src="/desktop/aim-desktop-icon.png" alt="Salon de discussion" />69+ }}
29- <span class="icon-label">Pdr Aim</span>70+ ondblclick={icon.action}
30- </div>71+ onkeydown={(e) => handleKeydown(e, icon.action)}
31-72+ onfocus={() => (selected = icon.id)}
32- <div73+ role="button"
33- class="desktop-icon"74+ tabindex="0"
34- ondblclick={handleAuthClick}75+ >
35- onkeydown={(e) => handleKeydown(e, handleAuthClick)}76+ <img src={icon.icon} alt="" />
36- role="button"77+ <span class="icon-label">{icon.label}</span>
37- tabindex="0"78+ </div>
38- >79+ {/each}
39- <img src="/desktop/application-x-shellscript.png" alt="Connexion" />
40- <span class="icon-label">Connexion</span>
41- </div>
42 </div>80 </div>
43 81
44 <style>82 <style>
45 .desktop-icons {83 .desktop-icons {
46 position: fixed;84 position: fixed;
47- top: 20px;85+ top: 16px;
48- left: 20px;86+ left: 12px;
49 display: flex;87 display: flex;
50 flex-direction: column;88 flex-direction: column;
51- gap: 20px;89+ gap: 10px;
90+ z-index: 1;
52 }91 }
53 92
54 .desktop-icon {93 .desktop-icon {
55 display: flex;94 display: flex;
56 flex-direction: column;95 flex-direction: column;
57 align-items: center;96 align-items: center;
58- cursor: pointer;97+ cursor: default;
59- width: 75px;98+ width: 80px;
60 text-align: center;99 text-align: center;
61- padding: 8px;100+ padding: 6px 4px;
62 border: 1px dotted transparent;101 border: 1px dotted transparent;
63 border-radius: 2px;102 border-radius: 2px;
64- margin: 2px;103+ outline: none;
65 }104 }
66 105
67- .desktop-icon:hover,106+ .desktop-icon:hover {
68- .desktop-icon:focus {107+ background: rgba(49, 106, 197, 0.15);
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;
73 }108 }
74 109
75- .desktop-icon:active {110+ .desktop-icon.selected,
76- background: rgba(49, 106, 197, 0.4);111+ .desktop-icon:focus-visible {
112+ background: rgba(49, 106, 197, 0.45);
113+ border-color: rgba(255, 255, 255, 0.8);
77 }114 }
78 115
79 .desktop-icon img {116 .desktop-icon img {
80- width: 48px;117+ width: 40px;
81- height: 48px;118+ height: 40px;
82- margin-bottom: 5px;119+ margin-bottom: 4px;
83- pointer-events: none; /* Prevents image from interfering with hover */120+ pointer-events: none;
121+ image-rendering: pixelated;
122+ filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.6));
84 }123 }
85 124
86 .icon-label {125 .icon-label {
87 color: white;126 color: white;
88- text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8);127+ text-shadow:
89- font-size: 12px;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;
90 user-select: none;132 user-select: none;
91- pointer-events: none; /* Prevents text from interfering with hover */133+ pointer-events: none;
92 max-width: 100%;134 max-width: 100%;
93 overflow-wrap: break-word;135 overflow-wrap: break-word;
136+ line-height: 1.2;
137+ }
138+
139+ @media (max-width: 768px) {
140+ .desktop-icons {
141+ display: none;
142+ }
94 }143 }
95 </style>144 </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 @@
4 import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';4 import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';
5 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';5 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';
6 import { highlightMentionsPlain } from '../utils/mention';6 import { highlightMentionsPlain } from '../utils/mention';
7+ import { replaceSmileys } from '$lib/aim/smileys';
7 8
8 // Props9 // Props
9 let { message, allowFormatting = true } = $props<{10 let { message, allowFormatting = true } = $props<{
@@ -20,7 +21,7 @@
20 async function processMessage() {21 async function processMessage() {
21 if (!allowFormatting || !message.hasFormatting) {22 if (!allowFormatting || !message.hasFormatting) {
22 // Still apply mention highlighting to plain text messages23 // Still apply mention highlighting to plain text messages
23- formattedContent = highlightMentionsPlain(escapeHtml(message.content));24+ formattedContent = replaceSmileys(highlightMentionsPlain(escapeHtml(message.content)));
24 return;25 return;
25 }26 }
26 27
@@ -52,7 +53,9 @@
52 53
53 // Regular formatted text (no gradient)54 // Regular formatted text (no gradient)
54 const styleWithoutGradient = { ...style, gradient: undefined };55 const styleWithoutGradient = { ...style, gradient: undefined };
55- formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting);56+ formattedContent = replaceSmileys(
57+ await formatText(message.content, styleWithoutGradient, allowFormatting)
58+ );
56 } catch (error) {59 } catch (error) {
57 console.error('Message formatting error:', error);60 console.error('Message formatting error:', error);
58 hasError = true;61 hasError = true;
@@ -180,6 +183,13 @@
180 {/if}183 {/if}
181 184
182 <style>185 <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+ }
183 .processing {193 .processing {
184 color: #666;194 color: #666;
185 font-style: italic;195 font-style: italic;
M src/lib/components/text-formatting-toolbar.svelte +101 -1
@@ -6,18 +6,24 @@
6 type FontFamily6 type FontFamily
7 } from '../types/text-formatting';7 } from '../types/text-formatting';
8 import ColorPicker256 from './color-picker-256.svelte';8 import ColorPicker256 from './color-picker-256.svelte';
9+ import { SMILEYS } from '$lib/aim/smileys';
9 10
10 // Props11 // Props
11 let {12 let {
12 style = $bindable(DEFAULT_TEXT_STYLE),13 style = $bindable(DEFAULT_TEXT_STYLE),
13 compact = true,14 compact = true,
14- showFontSelector = false15+ showFontSelector = false,
16+ onSmiley
15 } = $props<{17 } = $props<{
16 style?: TextStyle;18 style?: TextStyle;
17 compact?: boolean;19 compact?: boolean;
18 showFontSelector?: boolean;20 showFontSelector?: boolean;
21+ /** When set, shows the AIM smiley picker and inserts the chosen code */
22+ onSmiley?: (code: string) => void;
19 }>();23 }>();
20 24
25+ let smileyOpen = $state(false);
26+
21 // State27 // State
22 let currentColor = $state(style.color || '#000000');28 let currentColor = $state(style.color || '#000000');
23 let currentGradient = $state(style.gradient);29 let currentGradient = $state(style.gradient);
@@ -132,6 +138,38 @@
132 </button>138 </button>
133 </div>139 </div>
134 140
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+
135 <!-- Color controls -->173 <!-- Color controls -->
136 <div class="color-controls">174 <div class="color-controls">
137 <ColorPicker256175 <ColorPicker256
@@ -177,6 +215,68 @@
177 gap: 1px;215 gap: 1px;
178 }216 }
179 217
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+ }
180 .color-controls {280 .color-controls {
181 display: flex;281 display: flex;
182 gap: 1px;282 gap: 1px;
M src/lib/db/convex.server.ts +3 -0
@@ -153,6 +153,9 @@ export interface ConvexChatRoom {
153 name?: string;153 name?: string;
154 type: string;154 type: string;
155 createdAt: number;155 createdAt: number;
156+ topic?: string;
157+ memberIds?: string[];
158+ createdBy?: string;
156 }159 }
157 160
158 export interface ConvexTextPreferences {161 export interface ConvexTextPreferences {
M src/lib/states/chat.svelte.ts +14 -0
@@ -12,6 +12,8 @@ class ChatState {
12 // Current user state (from session)12 // Current user state (from session)
13 private _currentUser = $state<SafeUser | null>(null);13 private _currentUser = $state<SafeUser | null>(null);
14 private _currentRoomId = $state<string | null>(null);14 private _currentRoomId = $state<string | null>(null);
15+ // SHA-256 hash of the session token: used as `tokenHash` for Convex authQuery/authMutation
16+ private _sessionTokenHash = $state<string | null>(null);
15 17
16 // Connection state for UI feedback18 // Connection state for UI feedback
17 private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected');19 private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected');
@@ -43,6 +45,7 @@ class ChatState {
43 setCurrentUser(user: User | SafeUser | null) {45 setCurrentUser(user: User | SafeUser | null) {
44 if (!user) {46 if (!user) {
45 this._currentUser = null;47 this._currentUser = null;
48+ this._sessionTokenHash = null;
46 this._connectionStatus = 'disconnected';49 this._connectionStatus = 'disconnected';
47 this._roomMentionCounts = {};50 this._roomMentionCounts = {};
48 return;51 return;
@@ -53,6 +56,17 @@ class ChatState {
53 this._connectionStatus = 'connected';56 this._connectionStatus = 'connected';
54 }57 }
55 58
59+ /**
60+ * Session token hash for client-side authenticated Convex calls
61+ */
62+ getSessionTokenHash() {
63+ return this._sessionTokenHash;
64+ }
65+
66+ setSessionTokenHash(hash: string | null) {
67+ this._sessionTokenHash = hash;
68+ }
69+
56 /**70 /**
57 * Get the current room ID71 * Get the current room ID
58 */72 */
A src/lib/states/desktop.svelte.ts +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 {
8 createdAt: number; // Timestamp (e.g., Unix timestamp)8 createdAt: number; // Timestamp (e.g., Unix timestamp)
9 lastSeen?: number | null; // Optional last active timestamp9 lastSeen?: number | null; // Optional last active timestamp
10 lastReadMentionTimestamp?: number | null; // Timestamp of last read mention10 lastReadMentionTimestamp?: number | null; // Timestamp of last read mention
11+ awayMessage?: string; // AIM away message (only exposed while away)
11 }12 }
12 13
13 export type UserStatus = 'offline' | 'online' | 'away' | string;14 export type UserStatus = 'offline' | 'online' | 'away' | string;
M src/lib/validation/message.ts +3 -0
@@ -3,6 +3,9 @@ import { z } from 'zod';
3 // Message constraints3 // Message constraints
4 export const MAX_MESSAGE_LENGTH = 500;4 export const MAX_MESSAGE_LENGTH = 500;
5 export const MIN_MESSAGE_LENGTH = 1;5 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;
6 9
7 // Message content schema10 // Message content schema
8 export const messageContentSchema = z11 export const messageContentSchema = z
M src/routes/+layout.svelte +6 -1
@@ -196,7 +196,9 @@
196 $effect(() => {196 $effect(() => {
197 if (!browser) return;197 if (!browser) return;
198 198
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;
200 if (currentUserJson === lastUserUpdate) {202 if (currentUserJson === lastUserUpdate) {
201 console.debug('Skipping duplicate user update');203 console.debug('Skipping duplicate user update');
202 return;204 return;
@@ -227,6 +229,9 @@
227 if (data.session?.id) {229 if (data.session?.id) {
228 console.debug('Updating global chatState with data.user:', data.user);230 console.debug('Updating global chatState with data.user:', data.user);
229 chatState.setCurrentUser(data.user);231 chatState.setCurrentUser(data.user);
232+ chatState.setSessionTokenHash(data.session.id);
233+ } else {
234+ chatState.setCurrentUser(null);
230 }235 }
231 });236 });
232 </script>237 </script>
M src/routes/+page.svelte +84 -18
@@ -1,37 +1,103 @@
1 <script lang="ts">1 <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';
3 import DesktopIcons from '$lib/components/desktop-icons.svelte';5 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';
5 22
6 const props = $props();23 const props = $props();
7 const pageData = $derived(props.data);24 const pageData = $derived(props.data);
8- let showChatRoom = $state(true);
9- let showAuth = $state(false);
10 25
11- function handleLoginSuccess() {26+ const currentUser = $derived(chatState.getCurrentUser());
12- showAuth = false;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();
13 }58 }
14 </script>59 </script>
15 60
16-<div class="desktop content-wrapper">61+<div
17- <DesktopIcons bind:showChatRoom bind:showAuth />62+ class="desktop"
63+ class:signing-on={desktop.signingOn}
64+ onmousedown={onDesktopClick}
65+ role="presentation"
66+>
67+ <DesktopIcons />
18 68
19- {#if showChatRoom}69+ {#each desktop.windows as win (win.id)}
20- <ChatRoom bind:showChatRoom initialTextStyle={pageData.textStyle} />70+ {#if win.kind === 'chat-room'}
21- {/if}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}
22 92
23- {#if showAuth}93+ <Taskbar />
24- <AimLogin bind:showAuth onLoginSuccess={handleLoginSuccess} />
25- {/if}
26 </div>94 </div>
27 95
28 <style>96 <style>
29- .content-wrapper {97+ .desktop {
30 position: absolute;98 position: absolute;
31 inset: 0;99 inset: 0;
32- display: flex;
33- align-items: center;
34- justify-content: center;
35 box-sizing: border-box;100 box-sizing: border-box;
101+ overflow: hidden;
36 }102 }
37 </style>103 </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';
2 import { getDefaultChatRoomId } from '$lib/utils/chat.server';2 import { getDefaultChatRoomId } from '$lib/utils/chat.server';
3 import type { Message } from '$lib/types/chat';3 import type { Message } from '$lib/types/chat';
4 import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads';4 import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads';
5-import { error } from '@sveltejs/kit';5+import { error, isHttpError } from '@sveltejs/kit';
6 import { createLogger } from '$lib/utils/logger.server';6 import { createLogger } from '$lib/utils/logger.server';
7 import { sanitizeStyleData } from '$lib/validation/text-formatting';7 import { sanitizeStyleData } from '$lib/validation/text-formatting';
8-import { sendMessageSchema } from '$lib/validation/message';8+import { sendMessageSchema, PUBLIC_PREVIEW_MESSAGES } from '$lib/validation/message';
9 import type { RequestHandler } from './$types';9 import type { RequestHandler } from './$types';
10 10
11 const log = createLogger('chat-server');11 const log = createLogger('chat-server');
@@ -60,6 +60,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
60 }60 }
61 61
62 try {62 try {
63+ // Direct (IM) rooms are private to their two members
64+ const room = await chatRooms.getById(roomId);
65+ if (!room) {
66+ throw error(404, 'Chat room not found');
67+ }
68+ if (room.type === 'direct') {
69+ if (!locals.user || !(room.memberIds ?? []).includes(locals.user.id)) {
70+ throw error(403, 'Not a member of this conversation');
71+ }
72+ }
73+ // 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+
63 log.debug('Fetching messages from Convex', {78 log.debug('Fetching messages from Convex', {
64 beforeTimestamp,79 beforeTimestamp,
65 roomId,80 roomId,
@@ -79,7 +94,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
79 });94 });
80 }95 }
81 96
82- const fetchLimit = 100;97+ const fetchLimit = isPublic ? PUBLIC_PREVIEW_MESSAGES : 100;
83 98
84 const result = await messages.getByRoom(99 const result = await messages.getByRoom(
85 roomId,100 roomId,
@@ -109,6 +124,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
109 headers: { 'Content-Type': 'application/json' }124 headers: { 'Content-Type': 'application/json' }
110 });125 });
111 } catch (err) {126 } catch (err) {
127+ if (isHttpError(err)) throw err;
112 log.error('Error fetching messages:', { error: err });128 log.error('Error fetching messages:', { error: err });
113 throw error(500, 'Failed to fetch messages');129 throw error(500, 'Failed to fetch messages');
114 }130 }
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