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