detached b04c358

feat(desktop): AIM desktop experience — buddy list, IM windows, taskbar, sign-on, profiles, away messages

commit
b04c35896a583c23cbbf09d90dd6e4403e7e7673
author
Kit · · 2026-09-27 12:47 UTC
parent
2a6dd11
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
20 files · +3388 -722
M .gitignore +4 -1
@@ -30,4 +30,7 @@ docs/
30 migration/30 migration/
31 31
32 # Plans32 # Plans
33-docs/plans/⏎̸33+docs/plans/
34+# Local audio samples (copyrighted, never committed)
35+static/sounds/*
36+!static/sounds/README.md
M src/convex/aim.ts +4 -9
@@ -48,11 +48,7 @@ export function directKeyFor(a: Id<'users'>, b: Id<'users'>) {
48 return [a, b].sort().join(':');48 return [a, b].sort().join(':');
49 }49 }
50 50
51-export async function assertRoomAccess(51+export async function assertRoomAccess(ctx: QueryCtx, room: Doc<'chatRooms'>, userId: Id<'users'>) {
52- ctx: QueryCtx,
53- room: Doc<'chatRooms'>,
54- userId: Id<'users'>
55-) {
56 if (room.type === 'direct' && !(room.memberIds ?? []).includes(userId)) {52 if (room.type === 'direct' && !(room.memberIds ?? []).includes(userId)) {
57 throw new Error('Not a member of this conversation');53 throw new Error('Not a member of this conversation');
58 }54 }
@@ -147,9 +143,7 @@ export const getMyDirectRooms = authQuery({
147 };143 };
148 })144 })
149 );145 );
150- return result.sort(146+ return result.sort((a, b) => (b.lastMessage?.timestamp ?? 0) - (a.lastMessage?.timestamp ?? 0));
151- (a, b) => (b.lastMessage?.timestamp ?? 0) - (a.lastMessage?.timestamp ?? 0)
152- );
153 }147 }
154 });148 });
155 149
@@ -246,7 +240,8 @@ export const updateProfile = authMutation({
246 args: { profile: v.optional(v.string()), buddyIcon: v.optional(v.string()) },240 args: { profile: v.optional(v.string()), buddyIcon: v.optional(v.string()) },
247 handler: async (ctx, args) => {241 handler: async (ctx, args) => {
248 const patch: Partial<Doc<'users'>> = {};242 const patch: Partial<Doc<'users'>> = {};
249- if (args.profile !== undefined) patch.profile = args.profile.trim().slice(0, MAX_PROFILE_LENGTH);243+ if (args.profile !== undefined)
244+ patch.profile = args.profile.trim().slice(0, MAX_PROFILE_LENGTH);
250 if (args.buddyIcon !== undefined) {245 if (args.buddyIcon !== undefined) {
251 if (!/^[a-z0-9-]{1,32}$/.test(args.buddyIcon)) throw new Error('Invalid buddy icon');246 if (!/^[a-z0-9-]{1,32}$/.test(args.buddyIcon)) throw new Error('Invalid buddy icon');
252 patch.buddyIcon = args.buddyIcon;247 patch.buddyIcon = args.buddyIcon;
M src/lib/aim/client.ts +2 -4
@@ -29,10 +29,8 @@ export function tokenHash() {
29 */29 */
30 export function authQuery<Q extends FunctionReference<'query'>>(30 export function authQuery<Q extends FunctionReference<'query'>>(
31 fn: Q,31 fn: Q,
32- args: () => Omit<FunctionArgs<Q>, 'tokenHash'> | 'skip' = () => ({}) as Omit<32+ args: () => Omit<FunctionArgs<Q>, 'tokenHash'> | 'skip' = () =>
33- FunctionArgs<Q>,33+ ({}) as Omit<FunctionArgs<Q>, 'tokenHash'>
34- 'tokenHash'
35- >
36 ) {34 ) {
37 if (!convexAvailable) return null;35 if (!convexAvailable) return null;
38 return useQuery(fn, () => {36 return useQuery(fn, () => {
M src/lib/aim/sounds.ts +66 -7
@@ -1,9 +1,10 @@
1 /**1 /**
2- * AIM sound effects synthesized with the Web Audio API.2+ * AIM sound effects.
3 *3 *
4- * No audio files are shipped: everything is generated procedurally so the4+ * If a sample exists in /static/sounds/<name>.(mp3|wav|ogg) it is played;
5- * project stays free of copyrighted samples. Each sound is a small sketch5+ * otherwise a Web Audio sketch is synthesized. No audio files are shipped
6- * evoking the original:6+ * in the repository so the project stays free of copyrighted samples.
7+ * Each synthesized sound evokes the original:
7 * - doorOpen : "buddy in" — creak + soft thud8 * - doorOpen : "buddy in" — creak + soft thud
8 * - doorSlam : "buddy out" — sharp slam9 * - doorSlam : "buddy out" — sharp slam
9 * - imReceive : incoming IM "ding"10 * - imReceive : incoming IM "ding"
@@ -147,7 +148,14 @@ const players: Record<AimSound, (ac: AudioContext, dest: AudioNode, t: number) =
147 doorSlam(ac, dest, t) {148 doorSlam(ac, dest, t) {
148 thud(ac, dest, t, 1.0, 0.22);149 thud(ac, dest, t, 1.0, 0.22);
149 // door frame rattle150 // door frame rattle
150- tone(ac, dest, { type: 'square', freq: 90, freqEnd: 60, start: t + 0.02, duration: 0.12, gain: 0.12 });151+ tone(ac, dest, {
152+ type: 'square',
153+ freq: 90,
154+ freqEnd: 60,
155+ start: t + 0.02,
156+ duration: 0.12,
157+ gain: 0.12
158+ });
151 },159 },
152 imReceive(ac, dest, t) {160 imReceive(ac, dest, t) {
153 // Two-note ding, bright161 // Two-note ding, bright
@@ -185,8 +193,22 @@ const players: Record<AimSound, (ac: AudioContext, dest: AudioNode, t: number) =
185 // Handshake: answer tone, then chirps193 // Handshake: answer tone, then chirps
186 const h = t + 1.7;194 const h = t + 1.7;
187 tone(ac, dest, { freq: 2100, start: h, duration: 0.6, gain: 0.07 });195 tone(ac, dest, { freq: 2100, start: h, duration: 0.6, gain: 0.07 });
188- tone(ac, dest, { type: 'square', freq: 1200, freqEnd: 2400, start: h + 0.7, duration: 0.35, gain: 0.05 });196+ tone(ac, dest, {
189- tone(ac, dest, { type: 'square', freq: 2400, freqEnd: 1200, start: h + 1.1, duration: 0.35, gain: 0.05 });197+ type: 'square',
198+ freq: 1200,
199+ freqEnd: 2400,
200+ start: h + 0.7,
201+ duration: 0.35,
202+ gain: 0.05
203+ });
204+ tone(ac, dest, {
205+ type: 'square',
206+ freq: 2400,
207+ freqEnd: 1200,
208+ start: h + 1.1,
209+ duration: 0.35,
210+ gain: 0.05
211+ });
190 // Noise burst (training)212 // Noise burst (training)
191 const src = ac.createBufferSource();213 const src = ac.createBufferSource();
192 src.buffer = noiseBuffer(ac, 1.2);214 src.buffer = noiseBuffer(ac, 1.2);
@@ -212,10 +234,47 @@ export const SOUND_DURATIONS_MS: Record<AimSound, number> = {
212 modem: 4500234 modem: 4500
213 };235 };
214 236
237+// Sample cache: undefined = not probed yet, null = no file available
238+const samples = new Map<AimSound, AudioBuffer | null>();
239+const SAMPLE_EXTENSIONS = ['mp3', 'wav', 'ogg'];
240+
241+async function loadSample(ac: AudioContext, name: AimSound): Promise<AudioBuffer | null> {
242+ if (samples.has(name)) return samples.get(name) ?? null;
243+ samples.set(name, null);
244+ for (const ext of SAMPLE_EXTENSIONS) {
245+ try {
246+ const res = await fetch(`/sounds/${name}.${ext}`, { cache: 'force-cache' });
247+ if (!res.ok || !(res.headers.get('content-type') ?? '').startsWith('audio/')) continue;
248+ const buffer = await ac.decodeAudioData(await res.arrayBuffer());
249+ samples.set(name, buffer);
250+ return buffer;
251+ } catch {
252+ // try next extension
253+ }
254+ }
255+ return null;
256+}
257+
258+/** Warm the sample cache so the first playback is not delayed by a fetch. */
259+export function preloadSounds() {
260+ const ac = getContext();
261+ if (!ac) return;
262+ for (const name of Object.keys(players) as AimSound[]) void loadSample(ac, name);
263+}
264+
215 export function playSound(name: AimSound) {265 export function playSound(name: AimSound) {
216 if (!enabled) return;266 if (!enabled) return;
217 const ac = getContext();267 const ac = getContext();
218 if (!ac) return;268 if (!ac) return;
269+ const cached = samples.get(name);
270+ if (cached) {
271+ const src = ac.createBufferSource();
272+ src.buffer = cached;
273+ src.connect(ac.destination);
274+ src.start();
275+ return;
276+ }
277+ if (cached === undefined) void loadSample(ac, name);
219 const master = ac.createGain();278 const master = ac.createGain();
220 master.gain.value = 0.9;279 master.gain.value = 0.9;
221 master.connect(ac.destination);280 master.connect(ac.destination);
M src/lib/components/chat-room.svelte +599 -627
@@ -9,14 +9,14 @@
9 import { browser } from '$app/environment';9 import { browser } from '$app/environment';
10 import { env } from '$env/dynamic/public';10 import { env } from '$env/dynamic/public';
11 import { api } from '$lib/api/client';11 import { api } from '$lib/api/client';
12- import { draggable } from '$lib/actions/draggable';
13- import { resizable } from '$lib/actions/resizable';
14- import { maximizable } from '$lib/actions/maximizable';
15- import { minimizable, type MinimizableNode } from '$lib/actions/minimizable';
16 import LoadingButton from './ui/button-loading.svelte';12 import LoadingButton from './ui/button-loading.svelte';
17 import Tooltip from './ui/tooltip.svelte';13 import Tooltip from './ui/tooltip.svelte';
18 import LoadingDots from './ui/loading-dots.svelte';14 import LoadingDots from './ui/loading-dots.svelte';
19- import AimLogin from './aim-login.svelte';15+ import XpWindow from './desktop/xp-window.svelte';
16+ import { desktop } from '$lib/states/desktop.svelte';
17+ import { useAimClient } from '$lib/aim/client';
18+ import { getBuddyIcon } from '$lib/aim/buddy-icons';
19+ import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
20 import FormattedMessage from './formatted-message.svelte';20 import FormattedMessage from './formatted-message.svelte';
21 import TextFormattingToolbar from './text-formatting-toolbar.svelte';21 import TextFormattingToolbar from './text-formatting-toolbar.svelte';
22 import MentionPicker from './mention-picker.svelte';22 import MentionPicker from './mention-picker.svelte';
@@ -31,27 +31,44 @@
31 } from '../utils/mention';31 } from '../utils/mention';
32 import { DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting';32 import { DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting';
33 import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';33 import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';
34- import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state';34+ import { debounce } from '$lib/utils/chat-window-state';
35 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';35 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
36 36
37 // Props37 // Props
38- let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props<{38+ let {
39- showChatRoom?: boolean;39+ roomId: requestedRoomId = null,
40+ roomName = 'General',
41+ initialTextStyle = DEFAULT_TEXT_STYLE
42+ } = $props<{
43+ roomId?: Id<'chatRooms'> | null;
44+ roomName?: string;
40 initialTextStyle?: TextStyle;45 initialTextStyle?: TextStyle;
41 }>();46 }>();
42 47
48+ // svelte-ignore state_referenced_locally
49+ const windowId = requestedRoomId ? `room-${requestedRoomId}` : 'room-default';
50+
43 const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL);51 const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL);
52+ const aim = useAimClient();
44 53
45 // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============54 // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============
46 55
47- // Subscribe to default room (to get room ID)56+ // Subscribe to default room (to get room ID) when no explicit room was requested
48- const defaultRoomQuery = isClient ? useQuery(convexApi.queries.getDefaultRoomPublic, {}) : null;57+ // svelte-ignore state_referenced_locally
58+ const defaultRoomQuery =
59+ isClient && !requestedRoomId ? useQuery(convexApi.queries.getDefaultRoomPublic, {}) : null;
49 60
50 // Subscribe to users (buddy list) - real-time updates61 // Subscribe to users (buddy list) - real-time updates
51 const usersQuery = isClient ? useQuery(convexApi.queries.getUsersPublic, {}) : null;62 const usersQuery = isClient ? useQuery(convexApi.queries.getUsersPublic, {}) : null;
52 63
53- // Room ID from Convex query64+ // Room ID: explicit prop or the default room
54- const roomId = $derived(defaultRoomQuery?.data?.id as Id<'chatRooms'> | undefined);65+ const roomId = $derived(
66+ (requestedRoomId ?? defaultRoomQuery?.data?.id) as Id<'chatRooms'> | undefined
67+ );
68+
69+ // Room topic (from public group rooms list)
70+ const groupRoomsQuery = isClient ? useQuery(convexApi.aim.getGroupRooms, {}) : null;
71+ const roomTopic = $derived(groupRoomsQuery?.data?.find((r) => r.id === roomId)?.topic ?? null);
55 72
56 // Subscribe to messages - only when room ID is available73 // Subscribe to messages - only when room ID is available
57 const messagesQuery = $derived(74 const messagesQuery = $derived(
@@ -71,11 +88,20 @@
71 nickname: u.nickname,88 nickname: u.nickname,
72 status: u.status as SafeUser['status'],89 status: u.status as SafeUser['status'],
73 avatarUrl: u.avatarUrl ?? null,90 avatarUrl: u.avatarUrl ?? null,
74- lastSeen: u.lastSeen ?? null91+ lastSeen: u.lastSeen ?? null,
92+ awayMessage: u.awayMessage,
93+ buddyIcon: u.buddyIcon
75 }))94 }))
76 .sort((a, b) => {95 .sort((a, b) => {
77- const statusOrder: Record<string, number> = { online: 0, away: 1, busy: 2, offline: 3 };96+ const statusOrder: Record<string, number> = {
78- return (statusOrder[a.status] ?? 3) - (statusOrder[b.status] ?? 3);97+ online: 0,
98+ idle: 1,
99+ away: 2,
100+ busy: 3,
101+ offline: 4
102+ };
103+ const diff = (statusOrder[a.status] ?? 4) - (statusOrder[b.status] ?? 4);
104+ return diff !== 0 ? diff : a.nickname.localeCompare(b.nickname);
79 });105 });
80 });106 });
81 107
@@ -152,15 +178,57 @@
152 178
153 // ============ WINDOW STATE ============179 // ============ WINDOW STATE ============
154 180
155- let windowWidth = $state(800);
156- let windowHeight = $state(600);
157- let windowX = $state(0);
158- let windowY = $state(0);
159 let isMobile = $state(false);181 let isMobile = $state(false);
160 let showUserList = $state(false);182 let showUserList = $state(false);
161- let isMaximized = $state(false);183+
162- let isMinimized = $state(false);184+ // ============ USER LIST INTERACTIONS (AIM-style) ============
163- let isCentered = $state(true);185+
186+ let selectedUserId = $state<string | null>(null);
187+ let userMenu = $state<{ x: number; y: number; user: SafeUser } | null>(null);
188+ let roomPanel = $state<HTMLDivElement | null>(null);
189+
190+ async function openImWith(user: SafeUser) {
191+ if (!currentUser || user.id === currentUser.id) return;
192+ try {
193+ const { id } = await aim.getOrCreateDirectRoom(user.id as Id<'users'>);
194+ desktop.openIm({
195+ roomId: id,
196+ otherUserId: user.id as Id<'users'>,
197+ otherNickname: user.nickname
198+ });
199+ } catch (err) {
200+ console.error('Failed to open IM', err);
201+ }
202+ }
203+
204+ function openUserMenu(event: MouseEvent, user: SafeUser) {
205+ event.preventDefault();
206+ selectedUserId = user.id;
207+ const rect = roomPanel?.getBoundingClientRect();
208+ userMenu = {
209+ x: event.clientX - (rect?.left ?? 0),
210+ y: event.clientY - (rect?.top ?? 0),
211+ user
212+ };
213+ }
214+
215+ function closeUserMenu() {
216+ userMenu = null;
217+ }
218+
219+ function statusIcon(status: string) {
220+ const known: AimStatus[] = ['online', 'away', 'busy', 'idle', 'offline'];
221+ return `/aim/status-${known.includes(status as AimStatus) ? status : 'offline'}.svg`;
222+ }
223+
224+ function statusLabel(user: SafeUser) {
225+ const label = STATUS_LABELS_FR[(user.status as AimStatus) ?? 'offline'] ?? user.status;
226+ if (user.status === 'away' && user.awayMessage) return `${label} : ${user.awayMessage}`;
227+ if (user.status === 'offline') {
228+ return `${label} — dernière connexion : ${formatFrenchRelativeTimeSafe(user.lastSeen)}`;
229+ }
230+ return label;
231+ }
164 232
165 // ============ INPUT STATE ============233 // ============ INPUT STATE ============
166 234
@@ -206,10 +274,6 @@
206 let rateLimitWarning = $state<string | null>(null);274 let rateLimitWarning = $state<string | null>(null);
207 let isSendingMessage = $state(false);275 let isSendingMessage = $state(false);
208 276
209- // ============ AUTH STATE ============
210-
211- let showAuth = $state(false);
212-
213 // ============ SCROLL STATE ============277 // ============ SCROLL STATE ============
214 278
215 let isLoadingMore = $state(false);279 let isLoadingMore = $state(false);
@@ -241,7 +305,6 @@
241 );305 );
242 306
243 // User counts307 // User counts
244- const totalUsers = $derived(onlineUsers.length);
245 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));308 const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline'));
246 const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));309 const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline'));
247 310
@@ -635,8 +698,6 @@
635 pagedMessages = [...pagedMessages, ...newMessages];698 pagedMessages = [...pagedMessages, ...newMessages];
636 }699 }
637 700
638- const debouncedSaveWindowState = debounce(saveWindowState, 300);
639-
640 // Debounced function to update the server with last read mention timestamp701 // Debounced function to update the server with last read mention timestamp
641 const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => {702 const debouncedUpdateMentionTimestamp = debounce(async (timestamp: number) => {
642 if (!currentUser || !browser) return;703 if (!currentUser || !browser) return;
@@ -815,10 +876,6 @@
815 876
816 // ============ HANDLERS ============877 // ============ HANDLERS ============
817 878
818- function handleClose() {
819- showChatRoom = false;
820- }
821-
822 function shouldHighlightMention(message: EnrichedMessage) {879 function shouldHighlightMention(message: EnrichedMessage) {
823 if (!currentUser) return false;880 if (!currentUser) return false;
824 if (message.senderId === currentUser.id) return false;881 if (message.senderId === currentUser.id) return false;
@@ -909,51 +966,6 @@
909 }966 }
910 }967 }
911 968
912- function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
913- windowX = event.detail.x;
914- windowY = event.detail.y;
915- isCentered = false;
916- }
917-
918- interface MaximizableNode extends HTMLElement {
919- toggleMaximize: () => void;
920- }
921-
922- function handleMaximize(event: MouseEvent) {
923- const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;
924- if (node) {
925- if (isMinimized) {
926- const minimizableNode = node as unknown as MinimizableNode;
927- if (minimizableNode?.toggleMinimize) {
928- minimizableNode.toggleMinimize();
929- }
930- setTimeout(() => node.toggleMaximize(), 300);
931- } else {
932- node.toggleMaximize();
933- }
934- }
935- }
936-
937- function handleMaximizeEvent(
938- event: CustomEvent<{
939- isMaximized: boolean;
940- width: number;
941- height: number;
942- x: number;
943- y: number;
944- }>
945- ) {
946- isMaximized = event.detail.isMaximized;
947- windowWidth = event.detail.width;
948- windowHeight = event.detail.height;
949- windowX = event.detail.x;
950- windowY = event.detail.y;
951- }
952-
953- function handleMinimize(event: CustomEvent<{ isMinimized: boolean }>) {
954- isMinimized = event.detail.isMinimized;
955- }
956-
957 async function handleScroll(event: Event) {969 async function handleScroll(event: Event) {
958 const target = event.target as HTMLElement;970 const target = event.target as HTMLElement;
959 const { scrollTop } = target;971 const { scrollTop } = target;
@@ -1008,11 +1020,7 @@
1008 }1020 }
1009 1021
1010 function openSignup() {1022 function openSignup() {
1011- showAuth = true;1023+ desktop.openLogin('signup');
1012- }
1013-
1014- function handleLoginSuccess() {
1015- showAuth = false;
1016 }1024 }
1017 1025
1018 // ============ LIFECYCLE ============1026 // ============ LIFECYCLE ============
@@ -1021,101 +1029,28 @@
1021 if (!browser) return;1029 if (!browser) return;
1022 1030
1023 isMobile = window.innerWidth <= 768;1031 isMobile = window.innerWidth <= 768;
1024-
1025- if (!isMobile) {
1026- const savedState = loadWindowState();
1027- const maxWidth = window.innerWidth - 40;
1028- const maxHeight = window.innerHeight - 40;
1029-
1030- windowWidth = Math.min(savedState.width, maxWidth);
1031- windowHeight = Math.min(savedState.height, maxHeight);
1032-
1033- if (savedState.x === -1) {
1034- windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
1035- } else {
1036- windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth));
1037- }
1038- if (savedState.y === -1) {
1039- windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
1040- } else {
1041- windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight));
1042- }
1043-
1044- isMaximized = savedState.isMaximized;
1045- isMinimized = savedState.isMinimized;
1046- showUserList = savedState.showUserList;
1047- isCentered = savedState.isCentered;
1048- }
1049-
1050 const handleResize = () => {1032 const handleResize = () => {
1051- const wasMobile = isMobile;
1052 isMobile = window.innerWidth <= 768;1033 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 };1034 };
1091-
1092- if (isMobile) handleResize();
1093-
1094 window.addEventListener('resize', handleResize);1035 window.addEventListener('resize', handleResize);
1095 1036
1037+ const onGlobalClick = () => closeUserMenu();
1038+ const onGlobalKey = (e: KeyboardEvent) => {
1039+ if (e.key === 'Escape') closeUserMenu();
1040+ };
1041+ window.addEventListener('click', onGlobalClick);
1042+ window.addEventListener('keydown', onGlobalKey);
1043+
1096 return () => {1044 return () => {
1097 window.removeEventListener('resize', handleResize);1045 window.removeEventListener('resize', handleResize);
1046+ window.removeEventListener('click', onGlobalClick);
1047+ window.removeEventListener('keydown', onGlobalKey);
1098 if (cooldownInterval) clearInterval(cooldownInterval);1048 if (cooldownInterval) clearInterval(cooldownInterval);
1099 // Clean up canvas context used for text measurement1049 // Clean up canvas context used for text measurement
1100 mentionMeasureContext = null;1050 mentionMeasureContext = null;
1101 };1051 };
1102 });1052 });
1103 1053
1104- // Save window state when it changes
1105- $effect(() => {
1106- if (!browser || isMobile) return;
1107- debouncedSaveWindowState({
1108- width: windowWidth,
1109- height: windowHeight,
1110- x: windowX,
1111- y: windowY,
1112- isMaximized,
1113- isMinimized,
1114- showUserList,
1115- isCentered
1116- });
1117- });
1118-
1119 // Scroll selected mention option into view when selection changes1054 // Scroll selected mention option into view when selection changes
1120 $effect(() => {1055 $effect(() => {
1121 if (!mentionOpen || mentionSuggestions.length === 0) return;1056 if (!mentionOpen || mentionSuggestions.length === 0) return;
@@ -1125,196 +1060,146 @@
1125 });1060 });
1126 </script>1061 </script>
1127 1062
1128-{#if showChatRoom}1063+<XpWindow
1129- <div1064+ id={windowId}
1130- class="chat-window window"1065+ width={800}
1131- class:minimized={isMinimized}1066+ height={600}
1132- style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;"1067+ minWidth={420}
1133- use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}1068+ minHeight={420}
1134- use:resizable={{1069+ showMaximize
1135- enabled: !isMobile && !isMaximized && !isMinimized,1070+ bodyClass="chat-room-body"
1136- minWidth: 400,1071+>
1137- minHeight: 500,1072+ {#snippet menu()}
1138- maxWidth: window.innerWidth - 40,1073+ <button type="button" onclick={() => desktop.openBuddyList()}>Contacts</button>
1139- maxHeight: window.innerHeight - 401074+ <button type="button" onclick={() => desktop.openNewRoom()}>Nouveau salon</button>
1140- }}1075+ <button type="button" onclick={() => (showUserList = !showUserList)}>
1141- use:maximizable={{ enabled: !isMobile && !isMinimized, padding: 4 }}1076+ {showUserList || !isMobile ? 'Participants' : 'Participants'}
1142- use:minimizable={{ enabled: !isMobile }}1077+ </button>
1143- onmaximize={handleMaximizeEvent}1078+ {#if !currentUser}
1144- onminimize={handleMinimize}1079+ <button type="button" onclick={() => desktop.openLogin('signin')}>Se connecter</button>
1145- onresizemove={(e) => {1080+ {/if}
1146- windowWidth = Math.max(400, e.detail.width);1081+ {/snippet}
1147- windowHeight = Math.max(500, e.detail.height);1082+
1148- }}1083+ <div class="room-header">
1149- ondragmove={handleDragMove}1084+ <img src="/aim/chat-room.svg" alt="" width="16" height="16" />
1150- >1085+ <span class="room-name">{roomName}</span>
1151- <div class="title-bar">1086+ {#if roomTopic}
1152- <div1087+ <span class="room-topic">— {roomTopic}</span>
1153- class="title-bar-text"1088+ {/if}
1154- style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;"1089+ <span class="room-count">{usersOnline.length} en ligne</span>
1090+ {#if mentionUnreadCount > 0}
1091+ <span class="room-mentions"
1092+ >({mentionUnreadCount} mention{mentionUnreadCount > 1 ? 's' : ''})</span
1155 >1093 >
1156- Pdr Aim {#if mentionUnreadCount > 0}({mentionUnreadCount})1094+ {/if}
1157- {/if}{#if currentUser}1095+ {#if connectionError}
1158- - {currentUser.nickname}{:else}1096+ <span class="connection-error">⚠️ {connectionError}</span>
1159- - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}1097+ {/if}
1160- {#if connectionError && !isMinimized}1098+ </div>
1161- <span class="connection-error">⚠️ {connectionError}</span>1099+
1162- {/if}1100+ <div class="room-panel" bind:this={roomPanel}>
1163- </div>1101+ <div class="chat-container">
1164- <div class="title-bar-controls">1102+ {#if rateLimitWarning}
1165- {#if !isMobile}1103+ <div class="rate-limit-warning" class:with-progress={cooldownEndTime}>
1166- <button1104+ <span>{rateLimitWarning}</span>
1167- aria-label="Minimize"1105+ {#if cooldownEndTime}
1168- onclick={(e) => {1106+ <small>Vous pourrez envoyer un autre message dans {cooldownProgress.toFixed(1)}s</small>
1169- e.stopPropagation();1107+ {/if}
1170- const node = (e.currentTarget as HTMLElement).closest(1108+ </div>
1171- '.window'1109+ {/if}
1172- ) as MinimizableNode | null;
1173- if (node?.toggleMinimize) node.toggleMinimize();
1174- }}
1175- ></button>
1176- <button aria-label="Maximize" class:maximized={isMaximized} onclick={handleMaximize}
1177- ></button>
1178- <button onclick={handleClose} aria-label="Close"></button>
1179- {:else}
1180- <button
1181- aria-label="Afficher/Masquer la liste des contacts"
1182- onclick={() => (showUserList = !showUserList)}
1183- class="contacts-btn">👥</button
1184- >
1185- <button
1186- aria-label="Minimize"
1187- onclick={(e) => {
1188- e.stopPropagation();
1189- const node = (e.currentTarget as HTMLElement).closest(
1190- '.window'
1191- ) as MinimizableNode | null;
1192- if (node?.toggleMinimize) node.toggleMinimize();
1193- }}
1194- ></button>
1195- <button onclick={handleClose} aria-label="Close"></button>
1196- {/if}
1197- </div>
1198- </div>
1199 1110
1200- {#if !isMinimized}
1201 <div1111 <div
1202- class="window-body"1112+ class="sunken-panel chat-area"
1203- style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;"1113+ style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
1114+ onscroll={(e) => handleScroll(e)}
1115+ bind:this={chatArea}
1204 >1116 >
1205- {#if connectionError}1117+ {#if isInitialLoading}
1206- <div class="error-banner">1118+ <div class="initial-loading">
1207- {connectionError}1119+ <LoadingDots text="Chargement des messages" />
1120+ </div>
1121+ {:else if isLoadingMore}
1122+ <div class="loading-messages">
1123+ <LoadingDots text="Chargement" />
1208 </div>1124 </div>
1209 {/if}1125 {/if}
1210-1126+ {#if showRegistrationPrompt}
1211- <div1127+ <div class="registration-prompt">
1212- class="chat-container"1128+ <p>
1213- style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;"1129+ 👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button>pour lire le
1214- >1130+ reste du chat !
1215- {#if rateLimitWarning}1131+ </p>
1216- <div class="rate-limit-warning" class:with-progress={cooldownEndTime}>1132+ </div>
1217- <span>{rateLimitWarning}</span>1133+ {/if}
1218- {#if cooldownEndTime}1134+ {#each visibleMessages as message (message.id)}
1219- <small
1220- >Vous pourrez envoyer un autre message dans {cooldownProgress.toFixed(1)}s</small
1221- >
1222- {/if}
1223- </div>
1224- {/if}
1225-
1226 <div1135 <div
1227- class="sunken-panel chat-area"1136+ class="message {message.type} text"
1228- style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"1137+ class:mention-highlight={hasMentionHighlight[message.id]}
1229- onscroll={(e) => handleScroll(e)}1138+ data-message-id={message.id}
1230- bind:this={chatArea}1139+ data-mention={hasMentionHighlight[message.id]
1140+ ? `Mention de @${currentUser?.nickname ?? ''}`
1141+ : undefined}
1142+ aria-label={hasMentionHighlight[message.id]
1143+ ? `Message mentionnant ${currentUser?.nickname ?? ''}`
1144+ : undefined}
1145+ use:registerMentionNode
1231 >1146 >
1232- {#if isInitialLoading}1147+ {#if message.type === 'emote'}
1233- <div class="initial-loading">1148+ <span class="emote-text">
1234- <LoadingDots text="Chargement des messages" />1149+ <Tooltip
1235- </div>1150+ data={{
1236- {:else if isLoadingMore}1151+ text: formatFrenchDateTime(new Date(message.timestamp)),
1237- <div class="loading-messages">1152+ direction: 'left',
1238- <LoadingDots text="Chargement" />1153+ closeDelay: 1000,
1239- </div>1154+ touchBehavior: 'remove',
1240- {/if}1155+ interactive: false
1241- {#if showRegistrationPrompt}1156+ }}
1242- <div class="registration-prompt">1157+ >
1243- <p>1158+ <span class="nickname pointer-events-none">{message.user.nickname}</span>
1244- 👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button>pour lire1159+ </Tooltip>
1245- le reste du chat !1160+ {message.content}
1246- </p>1161+ </span>
1247- </div>1162+ {:else}
1248- {/if}1163+ <Tooltip
1249- {#each visibleMessages as message (message.id)}1164+ data={{
1250- <div1165+ text: formatFrenchDateTime(new Date(message.timestamp)),
1251- class="message {message.type} text"1166+ direction: 'left',
1252- class:mention-highlight={hasMentionHighlight[message.id]}1167+ closeDelay: 1000,
1253- data-message-id={message.id}1168+ touchBehavior: 'remove',
1254- data-mention={hasMentionHighlight[message.id]1169+ interactive: false
1255- ? `Mention de @${currentUser?.nickname ?? ''}`1170+ }}
1256- : undefined}
1257- aria-label={hasMentionHighlight[message.id]
1258- ? `Message mentionnant ${currentUser?.nickname ?? ''}`
1259- : undefined}
1260- use:registerMentionNode
1261 >1171 >
1262- {#if message.type === 'emote'}1172+ <span class="nickname pointer-events-none">{message.user.nickname}:</span>
1263- <span class="emote-text">1173+ </Tooltip>
1264- <Tooltip1174+ <span class="message-content">
1265- data={{1175+ <FormattedMessage {message} allowFormatting={true} />
1266- text: formatFrenchDateTime(new Date(message.timestamp)),1176+ </span>
1267- direction: 'left',1177+ {/if}
1268- closeDelay: 1000,
1269- touchBehavior: 'remove',
1270- interactive: false
1271- }}
1272- >
1273- <span class="nickname pointer-events-none">{message.user.nickname}</span>
1274- </Tooltip>
1275- {message.content}
1276- </span>
1277- {:else}
1278- <Tooltip
1279- data={{
1280- text: formatFrenchDateTime(new Date(message.timestamp)),
1281- direction: 'left',
1282- closeDelay: 1000,
1283- touchBehavior: 'remove',
1284- interactive: false
1285- }}
1286- >
1287- <span class="nickname pointer-events-none">{message.user.nickname}:</span>
1288- </Tooltip>
1289- <span class="message-content">
1290- <FormattedMessage {message} allowFormatting={true} />
1291- </span>
1292- {/if}
1293- </div>
1294- {/each}
1295 </div>1178 </div>
1179+ {/each}
1180+ </div>
1296 1181
1297- <!-- Text Formatting Toolbar -->1182+ <!-- Text Formatting Toolbar -->
1298- {#if currentUser}1183+ {#if currentUser}
1299- <div style="margin-bottom: 0.25rem;">1184+ <div style="margin-bottom: 0.25rem;">
1300- <TextFormattingToolbar1185+ <TextFormattingToolbar
1301- bind:style={currentTextStyle}1186+ bind:style={currentTextStyle}
1302- compact={true}1187+ compact={true}
1303- showFontSelector={true}1188+ showFontSelector={true}
1304- />1189+ />
1305- </div>1190+ </div>
1306- {/if}1191+ {/if}
1307 1192
1308- <div class="field-row input-container" style="margin: 0; position: relative;">1193+ <div class="field-row input-container" style="margin: 0; position: relative;">
1309- {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}1194+ {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}
1310- <div1195+ <div
1311- class="gradient-input-wrapper"1196+ class="gradient-input-wrapper"
1312- style="flex: 1; position: relative; background: white; overflow: hidden;"1197+ style="flex: 1; position: relative; background: white; overflow: hidden;"
1313- >1198+ >
1314- <!-- Gradient text overlay that syncs with input scroll -->1199+ <!-- Gradient text overlay that syncs with input scroll -->
1315- <span1200+ <span
1316- class="gradient-text-overlay retro-font-{currentTextStyle.fontFamily}"1201+ class="gradient-text-overlay retro-font-{currentTextStyle.fontFamily}"
1317- style="1202+ style="
1318 position: absolute;1203 position: absolute;
1319 left: 3px;1204 left: 3px;
1320 top: 50%;1205 top: 50%;
@@ -1323,66 +1208,66 @@
1323 white-space: nowrap;1208 white-space: nowrap;
1324 font-size: {currentTextStyle.fontSize}px;1209 font-size: {currentTextStyle.fontSize}px;
1325 {currentTextStyle.bold1210 {currentTextStyle.bold
1326- ? currentTextStyle.fontFamily === 'tahoma'1211+ ? currentTextStyle.fontFamily === 'tahoma'
1327- ? 'font-weight: 200;'1212+ ? 'font-weight: 200;'
1328- : 'font-weight: 700;'1213+ : 'font-weight: 700;'
1329- : ''}1214+ : ''}
1330 {currentTextStyle.italic ? 'font-style: italic;' : ''}1215 {currentTextStyle.italic ? 'font-style: italic;' : ''}
1331 {currentTextStyle.underline || currentTextStyle.strikethrough1216 {currentTextStyle.underline || currentTextStyle.strikethrough
1332- ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`1217+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1333- : ''}1218+ : ''}
1334 background: linear-gradient(to right, {currentTextStyle.gradient.join(', ')});1219 background: linear-gradient(to right, {currentTextStyle.gradient.join(', ')});
1335 -webkit-background-clip: text;1220 -webkit-background-clip: text;
1336 -webkit-text-fill-color: transparent;1221 -webkit-text-fill-color: transparent;
1337 background-clip: text;1222 background-clip: text;
1338 "1223 "
1339- aria-hidden="true"1224+ aria-hidden="true"
1340- >1225+ >
1341- <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->1226+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1342- {@html formattedInputMessage}1227+ {@html formattedInputMessage}
1343- </span>1228+ </span>
1344- <input1229+ <input
1345- type="text"1230+ type="text"
1346- bind:value={currentMessage}1231+ bind:value={currentMessage}
1347- maxlength={MAX_MESSAGE_LENGTH}1232+ maxlength={MAX_MESSAGE_LENGTH}
1348- class="styled-input retro-font-{currentTextStyle.fontFamily}"1233+ 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.bold1234+ style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
1350- ? currentTextStyle.fontFamily === 'tahoma'1235+ ? currentTextStyle.fontFamily === 'tahoma'
1351- ? 'font-weight: 200;'1236+ ? 'font-weight: 200;'
1352- : 'font-weight: 700;'1237+ : 'font-weight: 700;'
1353- : ''} {currentTextStyle.italic1238+ : ''} {currentTextStyle.italic
1354- ? 'font-style: italic;'1239+ ? 'font-style: italic;'
1355- : ''} {currentTextStyle.underline || currentTextStyle.strikethrough1240+ : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1356- ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`1241+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1357- : ''}"1242+ : ''}"
1358- onkeydown={handleInputKeydown}1243+ onkeydown={handleInputKeydown}
1359- onkeyup={handleInputKeyup}1244+ onkeyup={handleInputKeyup}
1360- oninput={handleInputChange}1245+ oninput={handleInputChange}
1361- onscroll={handleInputScroll}1246+ onscroll={handleInputScroll}
1362- onfocus={handleInputFocus}1247+ onfocus={handleInputFocus}
1363- onclick={handleInputClick}1248+ onclick={handleInputClick}
1364- bind:this={messageInput}1249+ bind:this={messageInput}
1365- placeholder={!currentUser1250+ placeholder={!currentUser
1366- ? 'Inscris-toi pour participer.'1251+ ? 'Inscris-toi pour participer.'
1367- : cooldownEndTime1252+ : cooldownEndTime
1368- ? `Patientez ${cooldownProgress.toFixed(1)}s...`1253+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1369- : 'Écrivez un message...'}1254+ : 'Écrivez un message...'}
1370- disabled={!currentUser || Boolean(cooldownEndTime)}1255+ disabled={!currentUser || Boolean(cooldownEndTime)}
1371- role="combobox"1256+ role="combobox"
1372- aria-autocomplete="list"1257+ aria-autocomplete="list"
1373- aria-controls={mentionOpen ? mentionListBoxId : undefined}1258+ aria-controls={mentionOpen ? mentionListBoxId : undefined}
1374- aria-expanded={mentionOpen}1259+ aria-expanded={mentionOpen}
1375- />1260+ />
1376- </div>1261+ </div>
1377- {:else}1262+ {:else}
1378- <div1263+ <div
1379- class="mention-input-wrapper"1264+ class="mention-input-wrapper"
1380- style="flex: 1; position: relative; background: white; overflow: hidden;"1265+ style="flex: 1; position: relative; background: white; overflow: hidden;"
1381- >1266+ >
1382- <!-- Text overlay with mention highlighting -->1267+ <!-- Text overlay with mention highlighting -->
1383- <span1268+ <span
1384- class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}"1269+ class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}"
1385- style="1270+ style="
1386 position: absolute;1271 position: absolute;
1387 left: 3px;1272 left: 3px;
1388 top: 50%;1273 top: 50%;
@@ -1392,132 +1277,195 @@
1392 font-size: {currentTextStyle.fontSize}px;1277 font-size: {currentTextStyle.fontSize}px;
1393 color: {currentTextStyle.color || '#000000'};1278 color: {currentTextStyle.color || '#000000'};
1394 {currentTextStyle.bold1279 {currentTextStyle.bold
1395- ? currentTextStyle.fontFamily === 'tahoma'1280+ ? currentTextStyle.fontFamily === 'tahoma'
1396- ? 'font-weight: 200;'1281+ ? 'font-weight: 200;'
1397- : 'font-weight: 700;'1282+ : 'font-weight: 700;'
1398- : ''}1283+ : ''}
1399 {currentTextStyle.italic ? 'font-style: italic;' : ''}1284 {currentTextStyle.italic ? 'font-style: italic;' : ''}
1400 {currentTextStyle.underline || currentTextStyle.strikethrough1285 {currentTextStyle.underline || currentTextStyle.strikethrough
1401- ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`1286+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1402- : ''}1287+ : ''}
1403 "1288 "
1404- aria-hidden="true"1289+ aria-hidden="true"
1405- >1290+ >
1406- <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->1291+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput -->
1407- {@html formattedInputMessage}1292+ {@html formattedInputMessage}
1408- </span>1293+ </span>
1409- <input1294+ <input
1410- type="text"1295+ type="text"
1411- bind:value={currentMessage}1296+ bind:value={currentMessage}
1412- maxlength={MAX_MESSAGE_LENGTH}1297+ maxlength={MAX_MESSAGE_LENGTH}
1413- class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}"1298+ 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.bold1299+ style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
1415- ? currentTextStyle.fontFamily === 'tahoma'1300+ ? currentTextStyle.fontFamily === 'tahoma'
1416- ? 'font-weight: 200;'1301+ ? 'font-weight: 200;'
1417- : 'font-weight: 700;'1302+ : 'font-weight: 700;'
1418- : ''} {currentTextStyle.italic1303+ : ''} {currentTextStyle.italic
1419- ? 'font-style: italic;'1304+ ? 'font-style: italic;'
1420- : ''} {currentTextStyle.underline || currentTextStyle.strikethrough1305+ : ''} {currentTextStyle.underline || currentTextStyle.strikethrough
1421- ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`1306+ ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};`
1422- : ''} caret-color: {currentTextStyle.color || 'black'};"1307+ : ''} caret-color: {currentTextStyle.color || 'black'};"
1423- onkeydown={handleInputKeydown}1308+ onkeydown={handleInputKeydown}
1424- onkeyup={handleInputKeyup}1309+ onkeyup={handleInputKeyup}
1425- oninput={handleInputChange}1310+ oninput={handleInputChange}
1426- onscroll={handleInputScroll}1311+ onscroll={handleInputScroll}
1427- onfocus={handleInputFocus}1312+ onfocus={handleInputFocus}
1428- onclick={handleInputClick}1313+ onclick={handleInputClick}
1429- bind:this={messageInput}1314+ bind:this={messageInput}
1430- placeholder={!currentUser1315+ placeholder={!currentUser
1431- ? 'Inscris-toi pour participer.'1316+ ? 'Inscris-toi pour participer.'
1432- : cooldownEndTime1317+ : cooldownEndTime
1433- ? `Patientez ${cooldownProgress.toFixed(1)}s...`1318+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1434- : 'Écrivez un message...'}1319+ : 'Écrivez un message...'}
1435- disabled={!currentUser || Boolean(cooldownEndTime)}
1436- role="combobox"
1437- aria-autocomplete="list"
1438- aria-controls={mentionOpen ? mentionListBoxId : undefined}
1439- aria-expanded={mentionOpen}
1440- />
1441- </div>
1442- {/if}
1443- <LoadingButton
1444- onclick={handleSubmit}
1445 disabled={!currentUser || Boolean(cooldownEndTime)}1320 disabled={!currentUser || Boolean(cooldownEndTime)}
1446- loading={isSendingMessage}1321+ role="combobox"
1447- text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'}1322+ aria-autocomplete="list"
1323+ aria-controls={mentionOpen ? mentionListBoxId : undefined}
1324+ aria-expanded={mentionOpen}
1448 />1325 />
1449- {#if showCharCounter}
1450- <span class="char-counter {charCounterClass}">
1451- {currentMessage.length}/{MAX_MESSAGE_LENGTH}
1452- </span>
1453- {/if}
1454 </div>1326 </div>
1455- {#if mentionOpen}1327+ {/if}
1456- <MentionPicker1328+ <LoadingButton
1457- suggestions={mentionSuggestions}1329+ onclick={handleSubmit}
1458- selectedIndex={mentionSelectedIndex}1330+ disabled={!currentUser || Boolean(cooldownEndTime)}
1459- activeId={mentionActiveId}1331+ loading={isSendingMessage}
1460- listBoxId={mentionListBoxId}1332+ text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : 'Envoyer'}
1461- listStyle={mentionListStyle}1333+ />
1462- isPortal={!mentionListInside}1334+ {#if showCharCounter}
1463- onSelect={applyMentionSelection}1335+ <span class="char-counter {charCounterClass}">
1464- />1336+ {currentMessage.length}/{MAX_MESSAGE_LENGTH}
1465- {/if}1337+ </span>
1466-1338+ {/if}
1467- {#if cooldownEndTime}1339+ </div>
1468- <div1340+ {#if mentionOpen}
1469- class="cooldown-progress"1341+ <MentionPicker
1470- style="width: {100 -1342+ suggestions={mentionSuggestions}
1471- ((cooldownProgress * 100) / (cooldownEndTime - Date.now())) * 1000}%"1343+ selectedIndex={mentionSelectedIndex}
1472- ></div>1344+ activeId={mentionActiveId}
1473- {/if}1345+ listBoxId={mentionListBoxId}
1474- </div>1346+ listStyle={mentionListStyle}
1347+ isPortal={!mentionListInside}
1348+ onSelect={applyMentionSelection}
1349+ />
1350+ {/if}
1351+
1352+ {#if cooldownEndTime}
1353+ <div
1354+ class="cooldown-progress"
1355+ style="width: {100 - ((cooldownProgress * 100) / (cooldownEndTime - Date.now())) * 1000}%"
1356+ ></div>
1357+ {/if}
1358+ </div>
1475 1359
1476- <!-- Online users list -->1360+ <!-- Participants (AIM chat room people list) -->
1361+ <div
1362+ class="sunken-panel users-list"
1363+ class:mobile={isMobile}
1364+ class:hidden={isMobile && !showUserList}
1365+ >
1366+ <p class="section-header">Participants ({usersOnline.length})</p>
1367+ {#each usersOnline as user (user.id)}
1477 <div1368 <div
1478- class="sunken-panel users-list"1369+ class="user {user.status}"
1479- class:mobile={isMobile}1370+ class:selected={selectedUserId === user.id}
1480- class:hidden={isMobile && !showUserList}1371+ class:me={currentUser?.id === user.id}
1481- style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"1372+ role="button"
1373+ tabindex="0"
1374+ title={statusLabel(user)}
1375+ onclick={(e) => {
1376+ e.stopPropagation();
1377+ selectedUserId = user.id;
1378+ closeUserMenu();
1379+ }}
1380+ ondblclick={() => openImWith(user)}
1381+ oncontextmenu={(e) => openUserMenu(e, user)}
1382+ onkeydown={(e) => {
1383+ if (e.key === 'Enter') openImWith(user);
1384+ }}
1482 >1385 >
1483- <!-- Online users section -->1386+ <span
1484- {#if usersOnline.length > 0}1387+ class="buddy-tile"
1485- <p class="section-header">En ligne ({usersOnline.length})</p>1388+ style="background: {getBuddyIcon(user.buddyIcon).bg}"
1486- {#each usersOnline as user (user.id)}1389+ aria-hidden="true">{getBuddyIcon(user.buddyIcon).emoji}</span
1487- <div class="user">{user.nickname}</div>1390+ >
1488- {/each}1391+ <img class="status-icon" src={statusIcon(user.status)} alt="" width="12" height="12" />
1489- {/if}1392+ <span class="nickname">{user.nickname}</span>
1490-
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>1393 </div>
1394+ {/each}
1395+
1396+ {#if usersOffline.length > 0}
1397+ <div class="section-separator"></div>
1398+ <p class="section-header offline-header">Hors ligne ({usersOffline.length})</p>
1399+ {#each usersOffline as user (user.id)}
1400+ <div
1401+ class="user offline"
1402+ class:selected={selectedUserId === user.id}
1403+ role="button"
1404+ tabindex="0"
1405+ title={statusLabel(user)}
1406+ onclick={(e) => {
1407+ e.stopPropagation();
1408+ selectedUserId = user.id;
1409+ closeUserMenu();
1410+ }}
1411+ ondblclick={() => openImWith(user)}
1412+ oncontextmenu={(e) => openUserMenu(e, user)}
1413+ onkeydown={(e) => {
1414+ if (e.key === 'Enter') openImWith(user);
1415+ }}
1416+ >
1417+ <img class="status-icon" src={statusIcon('offline')} alt="" width="12" height="12" />
1418+ <span class="nickname">{user.nickname}</span>
1419+ </div>
1420+ {/each}
1421+ {/if}
1422+ </div>
1423+
1424+ {#if userMenu}
1425+ <div
1426+ class="user-menu"
1427+ style="left: {userMenu.x}px; top: {userMenu.y}px;"
1428+ role="menu"
1429+ tabindex="-1"
1430+ onclick={(e) => e.stopPropagation()}
1431+ onkeydown={(e) => {
1432+ if (e.key === 'Escape') closeUserMenu();
1433+ }}
1434+ >
1435+ <div class="user-menu-title">{userMenu.user.nickname}</div>
1436+ {#if currentUser && userMenu.user.id !== currentUser.id}
1437+ <button
1438+ type="button"
1439+ role="menuitem"
1440+ onclick={() => {
1441+ const u = userMenu?.user;
1442+ closeUserMenu();
1443+ if (u) void openImWith(u);
1444+ }}>Envoyer un message instantané</button
1445+ >
1446+ <button
1447+ type="button"
1448+ role="menuitem"
1449+ onclick={() => {
1450+ const u = userMenu?.user;
1451+ closeUserMenu();
1452+ if (u) void aim.addBuddy(u.id as Id<'users'>).catch(console.error);
1453+ }}>Ajouter à ma liste de contacts</button
1454+ >
1455+ {/if}
1456+ <button
1457+ type="button"
1458+ role="menuitem"
1459+ onclick={() => {
1460+ const u = userMenu?.user;
1461+ closeUserMenu();
1462+ if (u) desktop.openProfile({ userId: u.id as Id<'users'>, nickname: u.nickname });
1463+ }}>Infos sur {userMenu.user.nickname}</button
1464+ >
1513 </div>1465 </div>
1514 {/if}1466 {/if}
1515 </div>1467 </div>
1516-{/if}1468+</XpWindow>
1517-
1518-{#if showAuth}
1519- <AimLogin bind:showAuth activeTab={'signup' as const} onLoginSuccess={handleLoginSuccess} />
1520-{/if}
1521 1469
1522 <style>1470 <style>
1523 .chat-area,1471 .chat-area,
@@ -1531,36 +1479,157 @@
1531 font-family: 'Pixelated MS Sans Serif', Arial, Verdana, Tahoma, sans-serif;1479 font-family: 'Pixelated MS Sans Serif', Arial, Verdana, Tahoma, sans-serif;
1532 }1480 }
1533 1481
1534- .title-bar {1482+ /* ===== Layout inside the XP window ===== */
1535- height: 2rem;1483+ .room-header {
1536- position: relative;
1537- cursor: default;
1538- user-select: none;
1539 display: flex;1484 display: flex;
1540 align-items: center;1485 align-items: center;
1541- padding: 0 0.5rem;1486+ gap: 6px;
1487+ padding: 3px 6px;
1488+ margin-bottom: 4px;
1489+ background: linear-gradient(180deg, #fff3a6 0%, #f6c700 100%);
1490+ border: 1px solid #c99a00;
1491+ font-family: Tahoma, 'Pixelated MS Sans Serif', sans-serif;
1492+ font-size: 11px;
1493+ flex: 0 0 auto;
1494+ min-width: 0;
1495+ }
1496+
1497+ .room-name {
1498+ font-weight: bold;
1499+ }
1500+
1501+ .room-topic {
1502+ color: #5a4a00;
1503+ overflow: hidden;
1504+ text-overflow: ellipsis;
1505+ white-space: nowrap;
1506+ min-width: 0;
1507+ flex: 1;
1542 }1508 }
1543 1509
1544- .title-bar:not(:has(button:hover)) {1510+ .room-count {
1545- cursor: move;1511+ margin-left: auto;
1512+ color: #5a4a00;
1513+ white-space: nowrap;
1546 }1514 }
1547 1515
1548- .chat-window {1516+ .room-mentions {
1549- position: fixed;1517+ font-weight: bold;
1550- box-sizing: border-box;1518+ color: #c00000;
1551- transition:1519+ white-space: nowrap;
1552- width 0.3s ease,
1553- height 0.3s ease,
1554- left 0.3s ease,
1555- top 0.3s ease;
1556- will-change: transform;
1557 }1520 }
1558 1521
1559- .chat-window.dragging {1522+ .room-panel {
1560- transition: none !important;1523+ position: relative;
1524+ flex: 1 1 auto;
1525+ min-height: 0;
1526+ display: flex;
1527+ gap: 6px;
1528+ }
1529+
1530+ .chat-container {
1531+ flex: 1 1 auto;
1532+ min-width: 0;
1533+ display: flex;
1534+ flex-direction: column;
1535+ }
1536+
1537+ .users-list {
1538+ width: 10.5rem;
1539+ flex: 0 0 auto;
1540+ padding: 0.4rem;
1541+ overflow-y: auto;
1542+ font-size: 0.85rem;
1543+ }
1544+
1545+ .user {
1546+ display: flex;
1547+ align-items: center;
1548+ gap: 4px;
1549+ padding: 1px 3px;
1550+ cursor: pointer;
1551+ border: 1px dotted transparent;
1561 user-select: none;1552 user-select: none;
1562 }1553 }
1563 1554
1555+ .user:hover {
1556+ background: #e8eefb;
1557+ }
1558+
1559+ .user.selected {
1560+ background: #316ac5;
1561+ color: #fff;
1562+ border-color: #fff;
1563+ }
1564+
1565+ .user.me .nickname {
1566+ font-weight: bold;
1567+ }
1568+
1569+ .user.away .nickname,
1570+ .user.idle .nickname {
1571+ color: #777;
1572+ font-style: italic;
1573+ }
1574+
1575+ .user.selected .nickname {
1576+ color: #fff;
1577+ }
1578+
1579+ .buddy-tile {
1580+ display: inline-flex;
1581+ align-items: center;
1582+ justify-content: center;
1583+ width: 16px;
1584+ height: 16px;
1585+ font-size: 11px;
1586+ border: 1px solid rgba(0, 0, 0, 0.25);
1587+ border-radius: 2px;
1588+ flex: 0 0 16px;
1589+ }
1590+
1591+ .status-icon {
1592+ flex: 0 0 12px;
1593+ }
1594+
1595+ .user-menu {
1596+ position: absolute;
1597+ z-index: 50;
1598+ min-width: 200px;
1599+ background: #fff;
1600+ border: 1px solid #aca899;
1601+ box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
1602+ padding: 2px;
1603+ font-family: Tahoma, 'Pixelated MS Sans Serif', sans-serif;
1604+ font-size: 11px;
1605+ display: flex;
1606+ flex-direction: column;
1607+ }
1608+
1609+ .user-menu-title {
1610+ font-weight: bold;
1611+ padding: 3px 20px;
1612+ border-bottom: 1px solid #aca899;
1613+ margin-bottom: 2px;
1614+ color: #444;
1615+ }
1616+
1617+ .user-menu button {
1618+ text-align: left;
1619+ background: transparent;
1620+ border: none;
1621+ box-shadow: none;
1622+ border-radius: 0;
1623+ padding: 3px 20px;
1624+ min-width: 0;
1625+ font-size: 11px;
1626+ }
1627+
1628+ .user-menu button:hover {
1629+ background: #316ac5;
1630+ color: #fff;
1631+ }
1632+
1564 .chat-area {1633 .chat-area {
1565 line-height: 1.4;1634 line-height: 1.4;
1566 }1635 }
@@ -1665,24 +1734,11 @@
1665 margin: 0.5rem 0;1734 margin: 0.5rem 0;
1666 }1735 }
1667 1736
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 {1737 .user.offline {
1678 color: var(--color-muted-foreground);1738 color: var(--color-muted-foreground);
1679 font-style: italic;1739 font-style: italic;
1680 }1740 }
1681 1741
1682- .user.offline .nickname {
1683- cursor: help;
1684- }
1685-
1686 .sunken-panel {1742 .sunken-panel {
1687 background: white;1743 background: white;
1688 border: 0.125rem inset #dfdfdf;1744 border: 0.125rem inset #dfdfdf;
@@ -1728,90 +1784,14 @@
1728 display: none !important;1784 display: none !important;
1729 }1785 }
1730 1786
1731- :global(.resize-handle) {
1732- position: absolute;
1733- bottom: 0;
1734- right: 0;
1735- width: 15px !important;
1736- height: 15px !important;
1737- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Cpath d='M11 11v-2h2v2h-2zm0-4h2v2h-2V7zm-2 2V7h2v2H9zm0 2v-2h2v2H9zm-2 0v-2h2v2H7z' fill='%23000'/%3E%3C/svg%3E");
1738- background-position: bottom right;
1739- background-repeat: no-repeat;
1740- cursor: se-resize !important;
1741- }
1742-
1743 @media (max-width: 768px) {1787 @media (max-width: 768px) {
1744- .window {1788+ .room-panel {
1745- position: fixed !important;
1746- top: 0 !important;
1747- left: 0 !important;
1748- right: 0 !important;
1749- bottom: 0 !important;
1750- width: 100% !important;
1751- height: 100% !important;
1752- transition: transform 0.3s ease !important;
1753- margin: 0 !important;
1754- border-radius: 0 !important;
1755- }
1756-
1757- .window.minimized {
1758- transform: translateY(calc(100% - 32px)) !important;
1759- height: 100% !important;
1760- }
1761-
1762- .window.minimized .title-bar {
1763- height: 32px;
1764- }
1765-
1766- .title-bar-controls button {
1767- width: 24px;
1768- height: 24px;
1769- padding: 0;
1770- margin: 0 2px;
1771- background-color: transparent;
1772- border: 1px solid transparent;
1773- position: relative;
1774- }
1775-
1776- .title-bar-controls button.contacts-btn {
1777- width: auto;
1778- padding: 0 0.5rem;
1779- font-size: 1.25rem;
1780- }
1781-
1782- .window.minimized .title-bar-controls button {
1783- opacity: 0.8;
1784- }
1785-
1786- .window.minimized .title-bar-controls button.contacts-btn {
1787- opacity: 0.8;
1788- }
1789-
1790- .window-body {
1791 flex-direction: column;1789 flex-direction: column;
1792 }1790 }
1793 1791
1794- .chat-container {
1795- margin-right: 0 !important;
1796- margin-bottom: 0 !important;
1797- height: 100%;
1798- }
1799-
1800 .users-list.mobile {1792 .users-list.mobile {
1801- position: fixed;1793+ width: 100%;
1802- bottom: 0;
1803- left: 0;
1804- right: 0;
1805- height: auto;
1806 max-height: 30vh;1794 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 }1795 }
1816 }1796 }
1817 1797
@@ -1992,14 +1972,6 @@
1992 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);1972 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
1993 }1973 }
1994 1974
1995- .minimized .title-bar {
1996- cursor: pointer;
1997- }
1998-
1999- .minimized .window-body {
2000- display: none;
2001- }
2002-
2003 .minimized :global(.resize-handle) {1975 .minimized :global(.resize-handle) {
2004 display: none;1976 display: none;
2005 }1977 }
M src/lib/components/desktop-icons.svelte +106 -52
@@ -1,5 +1,59 @@
1 <script lang="ts">1 <script lang="ts">
2- let { showChatRoom = $bindable(), showAuth = $bindable() } = $props();2+ import { desktop } from '$lib/states/desktop.svelte';
3+ import { chatState } from '$lib/states/chat.svelte';
4+
5+ const currentUser = $derived(chatState.getCurrentUser());
6+
7+ interface DesktopIcon {
8+ id: string;
9+ label: string;
10+ icon: string;
11+ action: () => void;
12+ hidden?: boolean;
13+ }
14+
15+ const icons = $derived<DesktopIcon[]>([
16+ {
17+ id: 'aim',
18+ label: 'PDR AIM',
19+ icon: '/desktop/aim-desktop-icon.png',
20+ action: () => (currentUser ? desktop.openBuddyList() : desktop.openLogin('signin'))
21+ },
22+ {
23+ id: 'general',
24+ label: 'Salon General',
25+ icon: '/aim/chat-room.svg',
26+ action: () => desktop.openChatRoom(null, 'General')
27+ },
28+ {
29+ id: 'login',
30+ label: 'Connexion',
31+ icon: '/desktop/application-x-shellscript.png',
32+ action: () => desktop.openLogin('signin'),
33+ hidden: Boolean(currentUser)
34+ },
35+ {
36+ id: 'away',
37+ label: 'Message d’absence',
38+ icon: '/aim/away.svg',
39+ action: () => desktop.openAway(),
40+ hidden: !currentUser
41+ },
42+ {
43+ id: 'prefs',
44+ label: 'Préférences',
45+ icon: '/aim/setup-icon.png',
46+ action: () => desktop.openPreferences()
47+ },
48+ {
49+ id: 'about',
50+ label: 'Aide',
51+ icon: '/aim/help-icon.png',
52+ action: () => desktop.openAbout()
53+ }
54+ ]);
55+
56+ let selected = $state<string | null>(null);
3 57
4 function handleKeydown(event: KeyboardEvent, action: () => void) {58 function handleKeydown(event: KeyboardEvent, action: () => void) {
5 if (event.key === 'Enter' || event.key === ' ') {59 if (event.key === 'Enter' || event.key === ' ') {
@@ -7,89 +61,89 @@
7 action();61 action();
8 }62 }
9 }63 }
10-
11- function handleChatRoomClick() {
12- showChatRoom = true;
13- }
14-
15- function handleAuthClick() {
16- showAuth = true;
17- }
18 </script>64 </script>
19 65
20 <div class="desktop-icons">66 <div class="desktop-icons">
21- <div67+ {#each icons.filter((i) => !i.hidden) as icon (icon.id)}
22- class="desktop-icon"68+ <div
23- ondblclick={handleChatRoomClick}69+ class="desktop-icon"
24- onkeydown={(e) => handleKeydown(e, handleChatRoomClick)}70+ class:selected={selected === icon.id}
25- role="button"71+ onclick={(e) => {
26- tabindex="0"72+ e.stopPropagation();
27- >73+ selected = icon.id;
28- <img src="/desktop/aim-desktop-icon.png" alt="Salon de discussion" />74+ }}
29- <span class="icon-label">Pdr Aim</span>75+ ondblclick={icon.action}
30- </div>76+ onkeydown={(e) => handleKeydown(e, icon.action)}
31-77+ onfocus={() => (selected = icon.id)}
32- <div78+ role="button"
33- class="desktop-icon"79+ tabindex="0"
34- ondblclick={handleAuthClick}80+ >
35- onkeydown={(e) => handleKeydown(e, handleAuthClick)}81+ <img src={icon.icon} alt="" />
36- role="button"82+ <span class="icon-label">{icon.label}</span>
37- tabindex="0"83+ </div>
38- >84+ {/each}
39- <img src="/desktop/application-x-shellscript.png" alt="Connexion" />
40- <span class="icon-label">Connexion</span>
41- </div>
42 </div>85 </div>
43 86
44 <style>87 <style>
45 .desktop-icons {88 .desktop-icons {
46 position: fixed;89 position: fixed;
47- top: 20px;90+ top: 16px;
48- left: 20px;91+ left: 12px;
49 display: flex;92 display: flex;
50 flex-direction: column;93 flex-direction: column;
51- gap: 20px;94+ gap: 10px;
95+ z-index: 1;
52 }96 }
53 97
54 .desktop-icon {98 .desktop-icon {
55 display: flex;99 display: flex;
56 flex-direction: column;100 flex-direction: column;
57 align-items: center;101 align-items: center;
58- cursor: pointer;102+ cursor: default;
59- width: 75px;103+ width: 80px;
60 text-align: center;104 text-align: center;
61- padding: 8px;105+ padding: 6px 4px;
62 border: 1px dotted transparent;106 border: 1px dotted transparent;
63 border-radius: 2px;107 border-radius: 2px;
64- margin: 2px;108+ outline: none;
65 }109 }
66 110
67- .desktop-icon:hover,111+ .desktop-icon:hover {
68- .desktop-icon:focus {112+ background: rgba(49, 106, 197, 0.15);
69- background: rgba(49, 106, 197, 0.2);
70- border: 1px dotted rgba(255, 255, 255, 0.8);
71- outline: 1px solid rgba(49, 106, 197, 0.6);
72- outline-offset: -1px;
73 }113 }
74 114
75- .desktop-icon:active {115+ .desktop-icon.selected,
76- background: rgba(49, 106, 197, 0.4);116+ .desktop-icon:focus-visible {
117+ background: rgba(49, 106, 197, 0.45);
118+ border-color: rgba(255, 255, 255, 0.8);
77 }119 }
78 120
79 .desktop-icon img {121 .desktop-icon img {
80- width: 48px;122+ width: 40px;
81- height: 48px;123+ height: 40px;
82- margin-bottom: 5px;124+ margin-bottom: 4px;
83- pointer-events: none; /* Prevents image from interfering with hover */125+ pointer-events: none;
126+ image-rendering: pixelated;
127+ filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.6));
84 }128 }
85 129
86 .icon-label {130 .icon-label {
87 color: white;131 color: white;
88- text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8);132+ text-shadow:
89- font-size: 12px;133+ 1px 1px 1px rgba(0, 0, 0, 0.9),
134+ 0 0 4px rgba(0, 0, 0, 0.6);
135+ font-family: Tahoma, 'Pixelated MS Sans Serif', sans-serif;
136+ font-size: 11px;
90 user-select: none;137 user-select: none;
91- pointer-events: none; /* Prevents text from interfering with hover */138+ pointer-events: none;
92 max-width: 100%;139 max-width: 100%;
93 overflow-wrap: break-word;140 overflow-wrap: break-word;
141+ line-height: 1.2;
142+ }
143+
144+ @media (max-width: 768px) {
145+ .desktop-icons {
146+ display: none;
147+ }
94 }148 }
95 </style>149 </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.svg" 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 +117 -0
@@ -0,0 +1,117 @@
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={300} resizableWindow={false}>
62+ <div class="content">
63+ <label for="away-preset">Message d’absence</label>
64+ <select id="away-preset" value={selected} onchange={selectPreset}>
65+ {#each presets as preset (preset)}<option value={preset}>{preset}</option>{/each}
66+ <option value="custom">Personnalisé…</option>
67+ </select>
68+ <label for="away-text">Votre message</label>
69+ <textarea id="away-text" maxlength="300" bind:value={text} oninput={editText}></textarea>
70+ <small>{text.length}/300</small>
71+ {#if error}<span role="alert">{error}</span>{/if}
72+ <div class="actions">
73+ <button disabled={pending || !text.trim()} onclick={() => apply(text)}
74+ >Je suis absent(e)</button
75+ >
76+ <button disabled={pending} onclick={() => apply(undefined)}>Je suis de retour</button>
77+ <button onclick={() => desktop.close('away')}>Annuler</button>
78+ </div>
79+ </div>
80+</XpWindow>
81+
82+<style>
83+ .content {
84+ display: flex;
85+ flex-direction: column;
86+ gap: 7px;
87+ height: 100%;
88+ font:
89+ 11px Tahoma,
90+ 'Pixelated MS Sans Serif',
91+ sans-serif;
92+ }
93+ select,
94+ textarea {
95+ width: 100%;
96+ box-sizing: border-box;
97+ font: inherit;
98+ }
99+ textarea {
100+ flex: 1;
101+ min-height: 65px;
102+ resize: none;
103+ }
104+ small {
105+ align-self: flex-end;
106+ }
107+ .actions {
108+ display: flex;
109+ gap: 4px;
110+ flex-wrap: wrap;
111+ justify-content: flex-end;
112+ margin-top: auto;
113+ }
114+ [role='alert'] {
115+ color: #a00;
116+ }
117+</style>
A src/lib/components/desktop/buddy-list.svelte +706 -0
@@ -0,0 +1,706 @@
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 { getBuddyIcon } from '$lib/aim/buddy-icons';
10+ import { playSound } from '$lib/aim/sounds';
11+ import { authQuery, useAimClient, convexAvailable } from '$lib/aim/client';
12+ import { chatState } from '$lib/states/chat.svelte';
13+ import { desktop } from '$lib/states/desktop.svelte';
14+ import XpWindow from './xp-window.svelte';
15+
16+ const aim = useAimClient();
17+ const buddyQuery = authQuery(api.aim.getMyBuddies, () => ({}));
18+ const directQuery = authQuery(api.aim.getMyDirectRooms, () => ({}));
19+ const meQuery = authQuery(api.aim.getMe, () => ({}));
20+ const roomsQuery = convexAvailable ? useQuery(api.aim.getGroupRooms, {}) : null;
21+ const usersQuery = convexAvailable ? useQuery(api.queries.getUsersPublic, {}) : null;
22+ const me = $derived(chatState.getCurrentUser());
23+ const buddies = $derived(buddyQuery?.data ?? []);
24+ const collapsed = new SvelteSet<string>();
25+ const flashing = new SvelteSet<string>();
26+ let selectedId = $state<Id<'users'> | null>(null);
27+ let editMode = $state(false);
28+ let panel = $state<'im' | 'add' | null>(null);
29+ let nickname = $state('');
30+ let addId = $state('');
31+ let addGroup = $state<string>('Buddies');
32+ let customGroup = $state('');
33+ let error = $state('');
34+ let dropdownMenu = $state<'aim' | 'contacts' | 'help' | null>(null);
35+ let context = $state<{ buddy: Buddy; x: number; y: number } | null>(null);
36+ let menuBar: HTMLDivElement;
37+ let contextElement = $state<HTMLDivElement>();
38+ let previous = new SvelteMap<string, AimStatus>();
39+ let initialized = false;
40+ const timers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
41+
42+ const sortedGroups = $derived.by(() => {
43+ const groups = new SvelteMap<string, Buddy[]>();
44+ for (const name of DEFAULT_BUDDY_GROUPS) groups.set(name, []);
45+ for (const buddy of buddies) {
46+ const name = buddy.group || 'Buddies';
47+ if (!groups.has(name)) groups.set(name, []);
48+ if (buddy.status !== 'offline') groups.get(name)!.push(buddy);
49+ }
50+ const rank: Record<AimStatus, number> = { online: 0, away: 1, idle: 2, busy: 3, offline: 4 };
51+ return [...groups.entries()]
52+ .sort(([a], [b]) => {
53+ const ai = DEFAULT_BUDDY_GROUPS.indexOf(a as (typeof DEFAULT_BUDDY_GROUPS)[number]);
54+ const bi = DEFAULT_BUDDY_GROUPS.indexOf(b as (typeof DEFAULT_BUDDY_GROUPS)[number]);
55+ return ai !== -1 && bi !== -1
56+ ? ai - bi
57+ : ai !== -1
58+ ? -1
59+ : bi !== -1
60+ ? 1
61+ : a.localeCompare(b);
62+ })
63+ .map(([name, members]) => ({
64+ name,
65+ members: members.sort(
66+ (a, b) => rank[a.status] - rank[b.status] || a.nickname.localeCompare(b.nickname)
67+ ),
68+ total: buddies.filter((b) => (b.group || 'Buddies') === name).length
69+ }));
70+ });
71+ const offline = $derived(
72+ buddies
73+ .filter((b) => b.status === 'offline')
74+ .sort((a, b) => a.nickname.localeCompare(b.nickname))
75+ );
76+ const availableUsers = $derived(
77+ (usersQuery?.data ?? [])
78+ .filter((u) => u.id !== me?.id && !buddies.some((b) => b.id === u.id))
79+ .sort((a, b) => a.nickname.localeCompare(b.nickname))
80+ );
81+ const selectedBuddy = $derived(buddies.find((b) => b.id === selectedId));
82+
83+ onMount(() => {
84+ try {
85+ const stored = JSON.parse(localStorage.getItem('pdraim-buddylist-collapsed') ?? '[]');
86+ if (Array.isArray(stored))
87+ for (const name of stored) if (typeof name === 'string') collapsed.add(name);
88+ } catch {
89+ /* Ignore malformed saved preferences. */
90+ }
91+ return () => {
92+ for (const timer of timers.values()) clearTimeout(timer);
93+ };
94+ });
95+
96+ $effect(() => {
97+ const data = buddyQuery?.data;
98+ if (!me || !data) {
99+ previous = new SvelteMap<string, AimStatus>();
100+ initialized = false;
101+ return;
102+ }
103+ const next = new SvelteMap<string, AimStatus>();
104+ for (const buddy of data) {
105+ next.set(buddy.id, buddy.status);
106+ const old = previous.get(buddy.id);
107+ if (!initialized || old === undefined || old === buddy.status) continue;
108+ if (old === 'offline' && buddy.status !== 'offline') {
109+ playSound('doorOpen');
110+ flashing.add(buddy.id);
111+ const existing = timers.get(buddy.id);
112+ if (existing) clearTimeout(existing);
113+ timers.set(
114+ buddy.id,
115+ setTimeout(() => {
116+ flashing.delete(buddy.id);
117+ timers.delete(buddy.id);
118+ }, 1500)
119+ );
120+ } else if (old !== 'offline' && buddy.status === 'offline') playSound('doorSlam');
121+ }
122+ previous = next;
123+ initialized = true;
124+ });
125+
126+ function toggleGroup(name: string) {
127+ if (collapsed.has(name)) collapsed.delete(name);
128+ else collapsed.add(name);
129+ localStorage.setItem('pdraim-buddylist-collapsed', JSON.stringify([...collapsed]));
130+ }
131+ function openPanel(which: 'im' | 'add') {
132+ panel = panel === which ? null : which;
133+ error = '';
134+ dropdownMenu = null;
135+ }
136+ async function openIm(id: Id<'users'>, name: string) {
137+ try {
138+ const room = await aim.getOrCreateDirectRoom(id);
139+ desktop.openIm({ roomId: room.id, otherUserId: id, otherNickname: name });
140+ panel = null;
141+ } catch {
142+ error = 'Impossible d’ouvrir la conversation.';
143+ }
144+ }
145+ async function startIm() {
146+ const user = usersQuery?.data?.find(
147+ (u) => u.nickname.toLocaleLowerCase() === nickname.trim().toLocaleLowerCase()
148+ );
149+ if (!user) {
150+ error = 'Pseudo introuvable.';
151+ return;
152+ }
153+ await openIm(user.id, user.nickname);
154+ }
155+ async function addBuddy() {
156+ const user = availableUsers.find((u) => u.id === addId);
157+ const group = addGroup === 'Autre…' ? customGroup.trim() : addGroup;
158+ if (!user || !group) {
159+ error = 'Choisissez un contact et un groupe.';
160+ return;
161+ }
162+ try {
163+ await aim.addBuddy(user.id, group);
164+ panel = null;
165+ addId = '';
166+ error = '';
167+ } catch {
168+ error = 'Impossible d’ajouter ce contact.';
169+ }
170+ }
171+ async function removeBuddy(id: Id<'users'>) {
172+ context = null;
173+ try {
174+ await aim.removeBuddy(id);
175+ if (selectedId === id) selectedId = null;
176+ } catch {
177+ error = 'Impossible de retirer ce contact.';
178+ }
179+ }
180+ function showContext(event: MouseEvent, buddy: Buddy) {
181+ event.preventDefault();
182+ selectedId = buddy.id;
183+ dropdownMenu = null;
184+ context = {
185+ buddy,
186+ x: Math.min(event.clientX, window.innerWidth - 190),
187+ y: Math.min(event.clientY, window.innerHeight - 95)
188+ };
189+ }
190+ function outside(event: MouseEvent) {
191+ const target = event.target as Node;
192+ if (dropdownMenu && !menuBar?.contains(target)) dropdownMenu = null;
193+ if (context && !contextElement?.contains(target)) context = null;
194+ }
195+ function escape(event: KeyboardEvent) {
196+ if (event.key === 'Escape') {
197+ dropdownMenu = null;
198+ context = null;
199+ panel = null;
200+ }
201+ }
202+ async function logout() {
203+ dropdownMenu = null;
204+ try {
205+ await fetch('/api/session/logout', { method: 'POST' });
206+ window.location.reload();
207+ } catch {
208+ error = 'Déconnexion impossible.';
209+ }
210+ }
211+ function tooltip(buddy: Buddy) {
212+ return `${STATUS_LABELS_FR[buddy.status]}${buddy.status === 'offline' && buddy.lastSeen ? ` — Dernière visite : ${new Date(buddy.lastSeen).toLocaleString('fr-FR')}` : ''}`;
213+ }
214+</script>
215+
216+<svelte:window onclick={outside} onkeydown={escape} />
217+
218+{#snippet buddyRow(buddy: Buddy)}
219+ <div class="row" class:selected={selectedId === buddy.id} class:flash={flashing.has(buddy.id)}>
220+ <button
221+ class="row-action"
222+ class:offline={buddy.status === 'offline'}
223+ class:away={buddy.status === 'away'}
224+ title={tooltip(buddy)}
225+ onclick={() => (selectedId = buddy.id)}
226+ ondblclick={() => openIm(buddy.id, buddy.nickname)}
227+ oncontextmenu={(e) => showContext(e, buddy)}
228+ >
229+ <img src={`/aim/status-${buddy.status}.svg`} alt="" width="12" height="12" />
230+ <span class="truncate">{buddy.nickname}{buddy.status === 'idle' ? ' (Inactif)' : ''}</span>
231+ {#if buddy.status === 'away'}<img src="/aim/away.svg" alt="" width="12" height="12" />{/if}
232+ </button>
233+ {#if editMode}<button
234+ class="remove"
235+ aria-label={`Retirer ${buddy.nickname}`}
236+ title={`Retirer ${buddy.nickname}`}
237+ onclick={() => removeBuddy(buddy.id)}>✕</button
238+ >{/if}
239+ </div>
240+{/snippet}
241+
242+<XpWindow id="buddy-list" width={230} height={520} minWidth={230} showMaximize={false}>
243+ {#snippet menu()}
244+ <div class="menus" bind:this={menuBar}>
245+ <div class="menu-wrap">
246+ <button
247+ aria-expanded={dropdownMenu === 'aim'}
248+ onclick={() => (dropdownMenu = dropdownMenu === 'aim' ? null : 'aim')}>Mon AIM</button
249+ >
250+ {#if dropdownMenu === 'aim'}<div class="dropdown">
251+ <button
252+ onclick={() => {
253+ dropdownMenu = null;
254+ desktop.openAway();
255+ }}>Message d’absence…</button
256+ >
257+ <button
258+ disabled={!me}
259+ onclick={() => {
260+ dropdownMenu = null;
261+ if (me)
262+ desktop.openProfile({ userId: me.id as Id<'users'>, nickname: me.nickname });
263+ }}>Modifier mon profil…</button
264+ >
265+ <button
266+ onclick={() => {
267+ dropdownMenu = null;
268+ desktop.openPreferences();
269+ }}>Préférences…</button
270+ >
271+ <hr />
272+ <button onclick={logout}>Se déconnecter</button>
273+ </div>{/if}
274+ </div>
275+ <div class="menu-wrap">
276+ <button
277+ aria-expanded={dropdownMenu === 'contacts'}
278+ onclick={() => (dropdownMenu = dropdownMenu === 'contacts' ? null : 'contacts')}
279+ >Contacts</button
280+ >
281+ {#if dropdownMenu === 'contacts'}<div class="dropdown">
282+ <button onclick={() => openPanel('add')}>Ajouter un contact…</button><button
283+ onclick={() => {
284+ dropdownMenu = null;
285+ desktop.openNewRoom();
286+ }}>Nouveau salon…</button
287+ >
288+ </div>{/if}
289+ </div>
290+ <div class="menu-wrap">
291+ <button
292+ aria-expanded={dropdownMenu === 'help'}
293+ onclick={() => (dropdownMenu = dropdownMenu === 'help' ? null : 'help')}>Aide</button
294+ >
295+ {#if dropdownMenu === 'help'}<div class="dropdown">
296+ <button
297+ onclick={() => {
298+ dropdownMenu = null;
299+ desktop.openAbout();
300+ }}>À propos de PDR AIM</button
301+ >
302+ </div>{/if}
303+ </div>
304+ </div>
305+ {/snippet}
306+ <div class="buddy-content">
307+ <header class="identity">
308+ {#if me}
309+ <div class="icon" style:background={getBuddyIcon(meQuery?.data?.buddyIcon).bg}>
310+ {getBuddyIcon(meQuery?.data?.buddyIcon).emoji}
311+ </div>
312+ <div class="identity-text">
313+ <strong
314+ ><img src="/aim/running-man.svg" alt="" width="16" height="16" /> {me.nickname}</strong
315+ ><span title={meQuery?.data?.awayMessage ?? ''}
316+ >{meQuery?.data?.status === 'away'
317+ ? `Absent${meQuery.data.awayMessage ? ` : ${meQuery.data.awayMessage}` : ''}`
318+ : (STATUS_LABELS_FR[(meQuery?.data?.status ?? me.status) as AimStatus] ??
319+ 'En ligne')}</span
320+ >
321+ </div>
322+ {:else}
323+ <div>
324+ <button onclick={() => desktop.openLogin('signin')}>Se connecter</button>
325+ <p>Connectez-vous pour voir vos contacts.</p>
326+ </div>
327+ {/if}
328+ </header>
329+ <div class="tabs">
330+ <button class:active={!editMode} aria-pressed={!editMode} onclick={() => (editMode = false)}
331+ >Online</button
332+ ><button class:active={editMode} aria-pressed={editMode} onclick={() => (editMode = true)}
333+ >List Setup</button
334+ >
335+ </div>
336+ <div class="sunken-panel tree">
337+ {#if me}
338+ {#each sortedGroups as group (group.name)}
339+ <button
340+ class="group"
341+ aria-expanded={!collapsed.has(group.name)}
342+ onclick={() => toggleGroup(group.name)}
343+ >{collapsed.has(group.name) ? '▶' : '▼'}
344+ {group.name} ({group.members.filter((buddy) => buddy.status === 'online')
345+ .length}/{group.total})</button
346+ >
347+ {#if !collapsed.has(group.name)}{#each group.members as buddy (buddy.id)}{@render buddyRow(
348+ buddy
349+ )}{/each}{/if}
350+ {/each}
351+ <button
352+ class="group"
353+ aria-expanded={!collapsed.has('Offline')}
354+ onclick={() => toggleGroup('Offline')}
355+ >{collapsed.has('Offline') ? '▶' : '▼'} Offline ({offline.length})</button
356+ >
357+ {#if !collapsed.has('Offline')}{#each offline as buddy (buddy.id)}{@render buddyRow(
358+ buddy
359+ )}{/each}{/if}
360+ <button
361+ class="group"
362+ aria-expanded={!collapsed.has('Salons')}
363+ onclick={() => toggleGroup('Salons')}
364+ >{collapsed.has('Salons') ? '▶' : '▼'} Salons ({roomsQuery?.data?.length ?? 0})</button
365+ >
366+ {#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
367+ class="list-entry"
368+ onclick={() => desktop.openChatRoom(room.id, room.name)}
369+ ><img src="/aim/chat-room.svg" alt="" width="12" height="12" />
370+ <span class="truncate">{room.name}</span></button
371+ >{/each}{/if}
372+ <button
373+ class="group"
374+ aria-expanded={!collapsed.has('Conversations')}
375+ onclick={() => toggleGroup('Conversations')}
376+ >{collapsed.has('Conversations') ? '▶' : '▼'} Conversations ({directQuery?.data?.length ??
377+ 0})</button
378+ >
379+ {#if !collapsed.has('Conversations')}{#each directQuery?.data ?? [] as room (room.id)}{#if room.other}<button
380+ class="list-entry"
381+ class:unread={room.unreadCount > 0}
382+ onclick={() =>
383+ desktop.openIm({
384+ roomId: room.id,
385+ otherUserId: room.other!.id,
386+ otherNickname: room.other!.nickname
387+ })}
388+ ><img src="/aim/im.svg" alt="" width="12" height="12" />
389+ <span class="truncate"
390+ >{room.other.nickname}{room.unreadCount ? ` (${room.unreadCount})` : ''}</span
391+ ></button
392+ >{/if}{/each}{/if}
393+ {/if}
394+ </div>
395+ {#if me && panel === 'im'}<form
396+ class="inline-panel"
397+ onsubmit={(e) => {
398+ e.preventDefault();
399+ void startIm();
400+ }}
401+ >
402+ <label for="im-nick">Pseudo du contact</label><input
403+ id="im-nick"
404+ bind:value={nickname}
405+ autocomplete="off"
406+ /><button type="submit">Ouvrir IM</button>
407+ </form>{/if}
408+ {#if me && panel === 'add'}<form
409+ class="inline-panel"
410+ onsubmit={(e) => {
411+ e.preventDefault();
412+ void addBuddy();
413+ }}
414+ >
415+ <label for="add-user">Contact</label><select id="add-user" bind:value={addId}
416+ ><option value="">Choisir…</option>{#each availableUsers as user (user.id)}<option
417+ value={user.id}>{user.nickname}</option
418+ >{/each}</select
419+ ><label for="add-group">Groupe</label><select id="add-group" bind:value={addGroup}
420+ >{#each DEFAULT_BUDDY_GROUPS as group (group)}<option value={group}>{group}</option
421+ >{/each}<option value="Autre…">Autre…</option></select
422+ >{#if addGroup === 'Autre…'}<input
423+ aria-label="Nom du groupe"
424+ bind:value={customGroup}
425+ />{/if}<button type="submit">Ajouter</button>
426+ </form>{/if}
427+ {#if error}<p class="error" role="alert">{error}</p>{/if}
428+ <div class="toolbar">
429+ <button disabled={!me} onclick={() => openPanel('im')}
430+ ><img src="/aim/im.svg" alt="" />IM</button
431+ >
432+ <button disabled={!me} onclick={() => desktop.openNewRoom()}
433+ ><img src="/aim/chat-room.svg" alt="" />Chat</button
434+ >
435+ <button
436+ disabled={!selectedBuddy}
437+ onclick={() => {
438+ if (selectedBuddy)
439+ desktop.openProfile({ userId: selectedBuddy.id, nickname: selectedBuddy.nickname });
440+ }}><img src="/aim/info.svg" alt="" />Infos</button
441+ >
442+ <button disabled={!me} onclick={() => desktop.openAway()}
443+ ><img src="/aim/away.svg" alt="" />Absent</button
444+ >
445+ <button disabled={!me} onclick={() => openPanel('add')}
446+ ><img src="/aim/door-open.svg" alt="" />Ajouter</button
447+ >
448+ </div>
449+ </div>
450+</XpWindow>
451+
452+{#if context}<div
453+ class="context dropdown"
454+ bind:this={contextElement}
455+ style:left={`${Math.max(0, context.x)}px`}
456+ style:top={`${Math.max(0, context.y)}px`}
457+ >
458+ <button
459+ onclick={() => {
460+ if (context) void openIm(context.buddy.id, context.buddy.nickname);
461+ context = null;
462+ }}>Envoyer un message instantané</button
463+ >
464+ <button
465+ onclick={() => {
466+ if (context)
467+ desktop.openProfile({ userId: context.buddy.id, nickname: context.buddy.nickname });
468+ context = null;
469+ }}>Infos sur {context.buddy.nickname}</button
470+ >
471+ <button
472+ onclick={() => {
473+ if (context) void removeBuddy(context.buddy.id);
474+ }}>Retirer de la liste</button
475+ >
476+ </div>{/if}
477+
478+<style>
479+ .buddy-content {
480+ display: flex;
481+ flex-direction: column;
482+ flex: 1;
483+ min-height: 0;
484+ font:
485+ 11px Tahoma,
486+ 'Pixelated MS Sans Serif',
487+ sans-serif;
488+ }
489+ .identity {
490+ display: flex;
491+ align-items: center;
492+ gap: 6px;
493+ min-height: 49px;
494+ padding: 4px;
495+ background: linear-gradient(#ffe880, #f6c700);
496+ border-bottom: 1px solid #c59d00;
497+ }
498+ .identity p {
499+ margin: 3px 0 0;
500+ }
501+ .icon {
502+ display: grid;
503+ place-items: center;
504+ width: 32px;
505+ height: 32px;
506+ flex: none;
507+ border: 1px solid #9c8700;
508+ font-size: 22px;
509+ }
510+ .identity-text {
511+ min-width: 0;
512+ display: flex;
513+ flex-direction: column;
514+ gap: 3px;
515+ }
516+ .identity-text strong {
517+ display: flex;
518+ align-items: center;
519+ white-space: nowrap;
520+ overflow: hidden;
521+ text-overflow: ellipsis;
522+ }
523+ .identity-text span,
524+ .truncate {
525+ overflow: hidden;
526+ text-overflow: ellipsis;
527+ white-space: nowrap;
528+ }
529+ .tabs {
530+ display: flex;
531+ padding-top: 4px;
532+ }
533+ .tabs button {
534+ border: 1px solid #aaa;
535+ border-bottom: 0;
536+ border-radius: 4px 4px 0 0;
537+ padding: 3px 9px;
538+ min-width: 0;
539+ font-size: 11px;
540+ background: #dedbd0;
541+ }
542+ .tabs button.active {
543+ background: #fff;
544+ font-weight: bold;
545+ }
546+ .tree {
547+ flex: 1;
548+ min-height: 0;
549+ overflow: auto;
550+ background: #fff;
551+ padding: 2px;
552+ }
553+ .group,
554+ .list-entry,
555+ .row-action {
556+ display: flex;
557+ align-items: center;
558+ width: 100%;
559+ min-width: 0;
560+ text-align: left;
561+ border: 0;
562+ border-radius: 0;
563+ box-shadow: none;
564+ font: inherit;
565+ cursor: default;
566+ }
567+ .group {
568+ background: #ece9d8;
569+ font-weight: bold;
570+ padding: 3px 2px;
571+ margin-top: 2px;
572+ }
573+ .list-entry,
574+ .row-action {
575+ background: transparent;
576+ padding: 2px 2px 2px 12px;
577+ gap: 4px;
578+ }
579+ .list-entry:hover,
580+ .row-action:hover,
581+ .row.selected .row-action {
582+ background: #316ac5;
583+ color: #fff;
584+ }
585+ .row {
586+ display: flex;
587+ align-items: center;
588+ }
589+ .row-action {
590+ flex: 1;
591+ }
592+ .row-action.offline,
593+ .row-action.away {
594+ color: #777;
595+ font-style: italic;
596+ }
597+ .row-action:hover,
598+ .row.selected .row-action {
599+ color: #fff;
600+ }
601+ .row-action img,
602+ .list-entry img {
603+ flex: none;
604+ }
605+ .remove {
606+ min-width: 17px;
607+ padding: 0;
608+ border: 0;
609+ box-shadow: none;
610+ color: #900;
611+ }
612+ .unread {
613+ font-weight: bold;
614+ }
615+ .inline-panel {
616+ display: flex;
617+ flex-direction: column;
618+ gap: 3px;
619+ padding: 4px;
620+ background: #ece9d8;
621+ }
622+ .inline-panel input,
623+ .inline-panel select {
624+ width: 100%;
625+ box-sizing: border-box;
626+ }
627+ .error {
628+ color: #a00;
629+ margin: 2px;
630+ }
631+ .toolbar {
632+ display: flex;
633+ justify-content: space-between;
634+ border-top: 1px solid #aca899;
635+ padding-top: 3px;
636+ }
637+ .toolbar button {
638+ display: flex;
639+ align-items: center;
640+ flex-direction: column;
641+ gap: 2px;
642+ min-width: 0;
643+ padding: 2px;
644+ font: inherit;
645+ }
646+ .toolbar img {
647+ width: 20px;
648+ height: 20px;
649+ }
650+ .menus {
651+ display: flex;
652+ }
653+ .menu-wrap {
654+ position: relative;
655+ }
656+ .dropdown {
657+ position: absolute;
658+ z-index: 10000;
659+ top: 100%;
660+ left: 0;
661+ min-width: 170px;
662+ padding: 2px;
663+ background: #fff;
664+ border: 1px solid #aca899;
665+ box-shadow: 2px 2px 4px #888;
666+ }
667+ .dropdown button {
668+ display: block;
669+ width: 100%;
670+ min-width: 0;
671+ padding: 4px 8px;
672+ text-align: left;
673+ border: 0;
674+ box-shadow: none;
675+ background: #fff;
676+ color: #000;
677+ white-space: nowrap;
678+ font:
679+ 11px Tahoma,
680+ sans-serif;
681+ }
682+ .dropdown button:hover:not(:disabled) {
683+ background: #316ac5;
684+ color: #fff;
685+ }
686+ .dropdown hr {
687+ margin: 2px 4px;
688+ }
689+ .context {
690+ position: fixed;
691+ top: auto;
692+ min-width: 185px;
693+ }
694+ @keyframes sign-on {
695+ 0%,
696+ 75% {
697+ background: #fff199;
698+ }
699+ 100% {
700+ background: transparent;
701+ }
702+ }
703+ .row.flash {
704+ animation: sign-on 1.5s ease-out;
705+ }
706+</style>
A src/lib/components/desktop/im-window.svelte +484 -0
@@ -0,0 +1,484 @@
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';
9+ import { getBuddyIcon } from '$lib/aim/buddy-icons';
10+ import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
11+ import { chatState } from '$lib/states/chat.svelte';
12+ import { desktop } from '$lib/states/desktop.svelte';
13+ import { api } from '$lib/api/client';
14+ import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting';
15+ import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
16+ import { debounce } from '$lib/utils/chat-window-state';
17+ import { formatFrenchTime } from '$lib/utils/date-format';
18+ import XpWindow from './xp-window.svelte';
19+ import FormattedMessage from '../formatted-message.svelte';
20+ import TextFormattingToolbar from '../text-formatting-toolbar.svelte';
21+
22+ let { roomId, otherUserId, otherNickname } = $props<{
23+ roomId: Id<'chatRooms'>;
24+ otherUserId: Id<'users'>;
25+ otherNickname: string;
26+ }>();
27+ const id = $derived(`im-${roomId}`);
28+ const aim = useAimClient();
29+ const messagesQuery = authQuery(convexApi.aim.getRoomMessages, () => ({ roomId }));
30+ const typingQuery = authQuery(convexApi.aim.getRoomTyping, () => ({ roomId }));
31+ const profileQuery = useQuery(convexApi.aim.getUserProfile, () => ({ userId: otherUserId }));
32+ const currentUser = $derived(chatState.getCurrentUser());
33+ const profile = $derived(profileQuery.data);
34+ const buddyIcon = $derived(getBuddyIcon(profile?.buddyIcon));
35+ const status = $derived((profile?.status ?? 'offline') as AimStatus);
36+ const messages = $derived.by<EnrichedMessage[]>(() =>
37+ (messagesQuery?.data ?? []).map((msg) => ({
38+ id: msg.id,
39+ chatRoomId: msg.chatRoomId,
40+ senderId: msg.senderId,
41+ content: msg.content,
42+ type: msg.type,
43+ timestamp: msg.timestamp,
44+ styleData: msg.styleData,
45+ hasFormatting: msg.hasFormatting ?? false,
46+ user: {
47+ id: msg.sender?.id ?? msg.senderId,
48+ nickname: msg.sender?.nickname ?? otherNickname,
49+ status: (msg.sender?.status ?? 'offline') as SafeUser['status'],
50+ avatarUrl: msg.sender?.avatarUrl ?? null,
51+ lastSeen: null
52+ }
53+ }))
54+ );
55+ let menuOpen = $state<'file' | 'contact' | null>(null);
56+ let draft = $state('');
57+ let textStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE });
58+ let styleReady = $state(false);
59+ let loadedStyle = $state('');
60+ let sending = $state(false);
61+ let error = $state('');
62+ let cooldownSeconds = $state(0);
63+ let cooldownEnd = 0;
64+ let cooldownTimer: ReturnType<typeof setInterval> | undefined;
65+ let typingTimer: ReturnType<typeof setTimeout> | undefined;
66+ let readTimer: ReturnType<typeof setTimeout> | undefined;
67+ let lastTypingAt = 0;
68+ let conversation: HTMLDivElement;
69+ let firstScroll = true;
70+ let previousLast: string | null = null;
71+ let previousRoom: string | null = null;
72+ const disabled = $derived(!currentUser || sending || cooldownSeconds > 0);
73+
74+ const saveStyle = debounce((style: TextStyle) => {
75+ void api.textPreferences.save({
76+ defaultStyle: style,
77+ allowFormatting: true,
78+ maxMessageLength: MAX_MESSAGE_LENGTH
79+ });
80+ }, 1500);
81+
82+ // Load once per signed-in identity; do not persist the fetched default as an edit.
83+ $effect(() => {
84+ const userId = currentUser?.id;
85+ styleReady = false;
86+ if (!userId) return;
87+ let active = true;
88+ void api.textPreferences
89+ .get()
90+ .then((prefs) => {
91+ if (!active) return;
92+ textStyle = { ...DEFAULT_TEXT_STYLE, ...prefs?.defaultStyle };
93+ loadedStyle = JSON.stringify(textStyle);
94+ styleReady = true;
95+ })
96+ .catch(() => {
97+ if (!active) return;
98+ loadedStyle = JSON.stringify(textStyle);
99+ styleReady = true;
100+ });
101+ return () => {
102+ active = false;
103+ };
104+ });
105+ $effect(() => {
106+ const style = JSON.stringify(textStyle);
107+ if (styleReady && currentUser && style !== loadedStyle) {
108+ loadedStyle = style;
109+ saveStyle(JSON.parse(style) as TextStyle);
110+ }
111+ });
112+
113+ function stopTyping() {
114+ if (typingTimer) clearTimeout(typingTimer);
115+ typingTimer = undefined;
116+ if (lastTypingAt) {
117+ lastTypingAt = 0;
118+ void aim.setTyping(roomId, false).catch(() => {});
119+ }
120+ }
121+ function onInput() {
122+ if (!currentUser) return;
123+ if (!draft.trim()) {
124+ stopTyping();
125+ return;
126+ }
127+ const now = Date.now();
128+ if (draft.trim() && now - lastTypingAt >= 3000) {
129+ lastTypingAt = now;
130+ void aim.setTyping(roomId, true).catch(() => {});
131+ }
132+ if (typingTimer) clearTimeout(typingTimer);
133+ typingTimer = setTimeout(stopTyping, 4000);
134+ }
135+ function startCooldown(retryAfter: number) {
136+ cooldownEnd = Date.now() + retryAfter;
137+ if (cooldownTimer) clearInterval(cooldownTimer);
138+ const update = () => {
139+ cooldownSeconds = Math.max(0, Math.ceil((cooldownEnd - Date.now()) / 1000));
140+ if (!cooldownSeconds && cooldownTimer) {
141+ clearInterval(cooldownTimer);
142+ cooldownTimer = undefined;
143+ }
144+ };
145+ update();
146+ if (cooldownSeconds) cooldownTimer = setInterval(update, 200);
147+ }
148+ async function send() {
149+ if (disabled || !draft.trim() || draft.length > MAX_MESSAGE_LENGTH || !currentUser) return;
150+ sending = true;
151+ error = '';
152+ const content = draft;
153+ try {
154+ const result = await api.chat.sendMessage({
155+ content,
156+ type: 'chat',
157+ userId: currentUser.id,
158+ chatRoomId: roomId,
159+ styleData: JSON.stringify(textStyle)
160+ });
161+ if (result.success) {
162+ if (draft === content) draft = '';
163+ stopTyping();
164+ playSound('imSend');
165+ } else if (result.isRateLimited && result.retryAfter) {
166+ startCooldown(result.retryAfter);
167+ } else {
168+ error = result.error ?? 'Impossible d’envoyer le message.';
169+ }
170+ } catch {
171+ error = 'Impossible d’envoyer le message.';
172+ } finally {
173+ sending = false;
174+ }
175+ }
176+ function onKeydown(event: KeyboardEvent) {
177+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
178+ event.preventDefault();
179+ void send();
180+ }
181+ }
182+
183+ $effect(() => {
184+ const room = roomId;
185+ const data = messagesQuery?.data;
186+ if (room !== previousRoom) {
187+ previousRoom = room;
188+ previousLast = null;
189+ firstScroll = true;
190+ }
191+ if (!data) return;
192+ const last = data.at(-1);
193+ const signature = last ? `${last.id}:${last.timestamp}` : '';
194+ if (
195+ previousLast !== null &&
196+ last &&
197+ signature !== previousLast &&
198+ last.senderId === otherUserId
199+ ) {
200+ playSound('imReceive');
201+ if (desktop.focusedId !== id) desktop.requestAttention(id);
202+ }
203+ const changed = signature !== previousLast;
204+ previousLast = signature;
205+ if (!changed || !conversation) return;
206+ const shouldScroll =
207+ firstScroll ||
208+ conversation.scrollTop + conversation.clientHeight >= conversation.scrollHeight - 40;
209+ firstScroll = false;
210+ if (shouldScroll) void tick().then(() => conversation?.scrollTo(0, conversation.scrollHeight));
211+ });
212+ $effect(() => {
213+ const last = messagesQuery?.data?.at(-1);
214+ const focused = desktop.focusedId === id && !desktop.get(id)?.minimized;
215+ if (!focused || !currentUser || !messagesQuery?.data) return;
216+ // Depend on the latest message and focus, not the mutation's result.
217+ void last;
218+ readTimer = setTimeout(() => {
219+ void aim.markRoomRead(roomId).catch(() => {});
220+ }, 500);
221+ return () => {
222+ if (readTimer) clearTimeout(readTimer);
223+ };
224+ });
225+ onMount(() => () => {
226+ stopTyping();
227+ if (cooldownTimer) clearInterval(cooldownTimer);
228+ if (readTimer) clearTimeout(readTimer);
229+ });
230+</script>
231+
232+<XpWindow {id} width={460} height={420} minWidth={320} minHeight={300} showMaximize>
233+ {#snippet menu()}
234+ <div class="menu-item">
235+ <button
236+ aria-expanded={menuOpen === 'file'}
237+ onclick={() => (menuOpen = menuOpen === 'file' ? null : 'file')}>Fichier</button
238+ >
239+ {#if menuOpen === 'file'}<div class="dropdown">
240+ <button
241+ onclick={() => {
242+ menuOpen = null;
243+ desktop.close(id);
244+ }}>Fermer</button
245+ >
246+ </div>{/if}
247+ </div>
248+ <div class="menu-item">
249+ <button
250+ aria-expanded={menuOpen === 'contact'}
251+ onclick={() => (menuOpen = menuOpen === 'contact' ? null : 'contact')}>Contact</button
252+ >
253+ {#if menuOpen === 'contact'}<div class="dropdown">
254+ <button
255+ onclick={() => {
256+ menuOpen = null;
257+ desktop.openProfile({ userId: otherUserId, nickname: otherNickname });
258+ }}>Infos sur {otherNickname}</button
259+ >
260+ </div>{/if}
261+ </div>
262+ {/snippet}
263+ <div class="im-body">
264+ <div class="header">
265+ <div
266+ class="buddy-icon"
267+ style:background={buddyIcon.bg}
268+ role="img"
269+ aria-label={buddyIcon.label}
270+ >
271+ {buddyIcon.emoji}
272+ </div>
273+ <div class="identity">
274+ <strong>{profile?.nickname ?? otherNickname}</strong><span
275+ ><img src={`/aim/status-${status}.svg`} alt="" width="14" height="14" />
276+ {STATUS_LABELS_FR[status] ?? 'Hors ligne'}</span
277+ >
278+ </div>
279+ </div>
280+ {#if status === 'away' && profile?.awayMessage}<div class="away">
281+ Message d'absence : {profile.awayMessage}
282+ </div>{/if}
283+ <div
284+ class="sunken-panel conversation"
285+ bind:this={conversation}
286+ role="log"
287+ aria-label={`Conversation avec ${otherNickname}`}
288+ >
289+ {#if !messagesQuery?.data}<div class="loading">Chargement…</div>{/if}
290+ {#each messages as message (message.id)}
291+ {#if message.type === 'system'}<div class="system">{message.content}</div>
292+ {:else}<div class="message">
293+ <span
294+ class:me={message.senderId === currentUser?.id}
295+ class="nick"
296+ title={new Date(message.timestamp).toLocaleString('fr-FR')}
297+ >{message.user.nickname} ({formatFrenchTime(new Date(message.timestamp)).replace(
298+ 'h',
299+ ':'
300+ )}):</span
301+ >
302+ <FormattedMessage {message} allowFormatting={true} />
303+ </div>{/if}
304+ {/each}
305+ {#if typingQuery?.data?.length}<div class="typing">
306+ {typingQuery.data.map((user) => user.nickname).join(', ')} est en train d'écrire<span
307+ class="dots">…</span
308+ >
309+ </div>{/if}
310+ </div>
311+ <TextFormattingToolbar bind:style={textStyle} compact={true} showFontSelector={true} />
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+ width: 48px;
375+ height: 48px;
376+ display: grid;
377+ place-items: center;
378+ font-size: 29px;
379+ border: 1px solid #aca899;
380+ flex: none;
381+ }
382+ .identity {
383+ display: flex;
384+ flex-direction: column;
385+ gap: 5px;
386+ min-width: 0;
387+ }
388+ .identity strong {
389+ overflow: hidden;
390+ text-overflow: ellipsis;
391+ white-space: nowrap;
392+ }
393+ .identity span {
394+ display: flex;
395+ align-items: center;
396+ gap: 3px;
397+ }
398+ .away {
399+ flex: none;
400+ background: #fff8cc;
401+ border: 1px solid #e1c466;
402+ padding: 5px;
403+ overflow-wrap: anywhere;
404+ }
405+ .conversation {
406+ flex: 1 1 auto;
407+ min-height: 0;
408+ background: white;
409+ overflow: auto;
410+ padding: 6px;
411+ font:
412+ 12px/1.5 Tahoma,
413+ 'Pixelated MS Sans Serif',
414+ sans-serif;
415+ }
416+ .message {
417+ margin-bottom: 5px;
418+ overflow-wrap: anywhere;
419+ white-space: pre-wrap;
420+ }
421+ .nick {
422+ font-weight: bold;
423+ color: #0000c0;
424+ }
425+ .nick.me {
426+ color: #c00000;
427+ }
428+ .system {
429+ text-align: center;
430+ color: #777;
431+ font-style: italic;
432+ margin: 6px 0;
433+ }
434+ .typing,
435+ .loading {
436+ color: #777;
437+ font-style: italic;
438+ }
439+ .dots {
440+ display: inline-block;
441+ animation: blink 1s infinite alternate;
442+ }
443+ @keyframes blink {
444+ from {
445+ opacity: 0.25;
446+ }
447+ to {
448+ opacity: 1;
449+ }
450+ }
451+ .compose {
452+ display: flex;
453+ align-items: stretch;
454+ gap: 6px;
455+ flex: none;
456+ }
457+ textarea {
458+ flex: 1;
459+ min-width: 0;
460+ resize: none;
461+ font:
462+ 12px Tahoma,
463+ sans-serif;
464+ }
465+ .send {
466+ align-self: flex-end;
467+ }
468+ .notice {
469+ color: #9b2600;
470+ }
471+ .counter {
472+ align-self: flex-end;
473+ font-size: 10px;
474+ color: #777;
475+ }
476+ .sr-only {
477+ position: absolute;
478+ width: 1px;
479+ height: 1px;
480+ overflow: hidden;
481+ clip: rect(0, 0, 0, 0);
482+ white-space: nowrap;
483+ }
484+</style>
A src/lib/components/desktop/new-room-window.svelte +90 -0
@@ -0,0 +1,90 @@
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+ maxlength="40"
46+ minlength="2"
47+ bind:value={name}
48+ oninput={() => (error = '')}
49+ />
50+ <label for="room-topic">Sujet (facultatif)</label>
51+ <input id="room-topic" maxlength="120" bind:value={topic} oninput={() => (error = '')} />
52+ {#if error}<span role="alert">{error}</span>{/if}
53+ {:else}
54+ <p>Connectez-vous pour créer un salon.</p>
55+ {/if}
56+ <div class="actions">
57+ {#if signedIn}<button disabled={pending} onclick={create}>Créer</button>{:else}<button
58+ onclick={() => desktop.openLogin('signin')}>Se connecter</button
59+ >{/if}
60+ <button onclick={() => desktop.close('new-room')}>Annuler</button>
61+ </div>
62+ </div>
63+</XpWindow>
64+
65+<style>
66+ .content {
67+ display: flex;
68+ flex-direction: column;
69+ gap: 7px;
70+ height: 100%;
71+ font:
72+ 11px Tahoma,
73+ 'Pixelated MS Sans Serif',
74+ sans-serif;
75+ }
76+ input {
77+ width: 100%;
78+ box-sizing: border-box;
79+ font: inherit;
80+ }
81+ .actions {
82+ display: flex;
83+ justify-content: flex-end;
84+ gap: 6px;
85+ margin-top: auto;
86+ }
87+ [role='alert'] {
88+ color: #a00;
89+ }
90+</style>
A src/lib/components/desktop/preferences-window.svelte +86 -0
@@ -0,0 +1,86 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import XpWindow from './xp-window.svelte';
4+ import { desktop } from '$lib/states/desktop.svelte';
5+ import {
6+ soundsEnabled,
7+ setSoundsEnabled,
8+ playSound,
9+ unlockAudio,
10+ type AimSound
11+ } from '$lib/aim/sounds';
12+
13+ const sounds: { label: string; name: AimSound }[] = [
14+ { label: "Porte qui s'ouvre", name: 'doorOpen' },
15+ { label: 'Porte qui claque', name: 'doorSlam' },
16+ { label: 'Message reçu', name: 'imReceive' },
17+ { label: 'Message envoyé', name: 'imSend' },
18+ { label: 'Bienvenue', name: 'welcome' },
19+ { label: 'Modem 56k', name: 'modem' }
20+ ];
21+ let soundOn = $state(true);
22+ let timestamps = $state(true);
23+ onMount(() => {
24+ soundOn = soundsEnabled();
25+ timestamps = localStorage.getItem('pdraim-show-timestamps') !== 'false';
26+ });
27+ function changeSound() {
28+ setSoundsEnabled(soundOn);
29+ if (soundOn) unlockAudio();
30+ }
31+ function changeTimestamps() {
32+ localStorage.setItem('pdraim-show-timestamps', String(timestamps));
33+ }
34+</script>
35+
36+<XpWindow id="preferences" width={380} height={320} resizableWindow={false}>
37+ <div class="content">
38+ <label
39+ ><input type="checkbox" bind:checked={soundOn} onchange={changeSound} /> Sons activés (porte, ding…)</label
40+ >
41+ <div class="sound-list">
42+ {#each sounds as sound (sound.name)}
43+ <div class="sound-row">
44+ <span>{sound.label}</span><button
45+ disabled={!soundOn}
46+ onclick={() => {
47+ unlockAudio();
48+ playSound(sound.name);
49+ }}>Tester</button
50+ >
51+ </div>
52+ {/each}
53+ </div>
54+ <label
55+ ><input type="checkbox" bind:checked={timestamps} onchange={changeTimestamps} /> Afficher les horodatages</label
56+ >
57+ <div class="actions"><button onclick={() => desktop.close('preferences')}>Fermer</button></div>
58+ </div>
59+</XpWindow>
60+
61+<style>
62+ .content {
63+ display: flex;
64+ flex-direction: column;
65+ gap: 8px;
66+ height: 100%;
67+ overflow: auto;
68+ font:
69+ 11px Tahoma,
70+ 'Pixelated MS Sans Serif',
71+ sans-serif;
72+ }
73+ .sound-list {
74+ display: grid;
75+ gap: 3px;
76+ }
77+ .sound-row {
78+ display: flex;
79+ align-items: center;
80+ justify-content: space-between;
81+ }
82+ .actions {
83+ margin-top: auto;
84+ align-self: flex-end;
85+ }
86+</style>
A src/lib/components/desktop/profile-window.svelte +201 -0
@@ -0,0 +1,201 @@
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 { BUDDY_ICONS, getBuddyIcon } from '$lib/aim/buddy-icons';
10+ import { STATUS_LABELS_FR } from '$lib/types/aim';
11+ import { formatFrenchDateTime } from '$lib/utils/date-format';
12+
13+ let { userId, nickname } = $props<{ userId: Id<'users'>; nickname: string }>();
14+ const id = $derived(`profile-${userId}`);
15+ const profileQuery = useQuery(api.aim.getUserProfile, () => ({ userId }));
16+ const client = useAimClient();
17+ const profile = $derived(profileQuery.data);
18+ const own = $derived(userId === chatState.getCurrentUser()?.id);
19+ let text = $state('');
20+ let icon = $state('runner');
21+ let saved = $state(false);
22+ let saving = $state(false);
23+ let error = $state('');
24+ let initializedFor = $state<string | null>(null);
25+ $effect(() => {
26+ if (profile && initializedFor !== userId) {
27+ text = profile.profile ?? '';
28+ icon = profile.buddyIcon ?? 'runner';
29+ initializedFor = userId;
30+ }
31+ });
32+
33+ async function save() {
34+ saving = true;
35+ saved = false;
36+ error = '';
37+ try {
38+ await client.updateProfile({ profile: text, buddyIcon: icon });
39+ saved = true;
40+ } catch (err) {
41+ error = err instanceof Error ? err.message : 'Impossible d’enregistrer.';
42+ } finally {
43+ saving = false;
44+ }
45+ }
46+</script>
47+
48+<XpWindow {id} width={380} height={400} minWidth={300} minHeight={260}>
49+ <div class="profile-content">
50+ {#if profile}
51+ <div class="identity">
52+ <div
53+ class="buddy-icon"
54+ style:background={getBuddyIcon(profile.buddyIcon).bg}
55+ aria-label={getBuddyIcon(profile.buddyIcon).label}
56+ >
57+ {getBuddyIcon(profile.buddyIcon).emoji}
58+ </div>
59+ <div>
60+ <strong>{profile.nickname}</strong>
61+ <div class="status">
62+ <img
63+ src={`/aim/status-${profile.status}.svg`}
64+ alt=""
65+ width="16"
66+ height="16"
67+ />{STATUS_LABELS_FR[profile.status]}
68+ </div>
69+ {#if profile.createdAt}<small
70+ >Membre depuis {formatFrenchDateTime(new Date(profile.createdAt))}</small
71+ >{/if}
72+ </div>
73+ </div>
74+ {#if profile.awayMessage}<div class="away-message">{profile.awayMessage}</div>{/if}
75+ <label for="profile-text">Profil</label>
76+ {#if own}
77+ <textarea
78+ id="profile-text"
79+ class="sunken-panel"
80+ maxlength="1000"
81+ bind:value={text}
82+ oninput={() => (saved = false)}
83+ ></textarea>
84+ <small>{text.length}/1000</small>
85+ <div class="icon-grid" aria-label="Choisir une icône">
86+ {#each BUDDY_ICONS as preset (preset.id)}
87+ <button
88+ class:selected={icon === preset.id}
89+ aria-label={preset.label}
90+ aria-pressed={icon === preset.id}
91+ title={preset.label}
92+ style:background={preset.bg}
93+ onclick={() => {
94+ icon = preset.id;
95+ saved = false;
96+ }}>{preset.emoji}</button
97+ >
98+ {/each}
99+ </div>
100+ {:else}
101+ <div id="profile-text" class="sunken-panel profile-text">
102+ {profile.profile ?? 'Aucun profil pour le moment.'}
103+ </div>
104+ {/if}
105+ {:else if profile === null}
106+ <p>Profil introuvable : {nickname}</p>
107+ {:else}
108+ <p>Chargement du profil…</p>
109+ {/if}
110+ <div class="actions">
111+ {#if error}<span role="alert">{error}</span>{/if}
112+ {#if saved}<span role="status">Enregistré ✓</span>{/if}
113+ {#if own && profile}<button disabled={saving || text.length > 1000} onclick={save}
114+ >Enregistrer</button
115+ >{/if}
116+ <button onclick={() => desktop.close(id)}>Fermer</button>
117+ </div>
118+ </div>
119+</XpWindow>
120+
121+<style>
122+ .profile-content {
123+ display: flex;
124+ flex-direction: column;
125+ gap: 6px;
126+ height: 100%;
127+ overflow: auto;
128+ font:
129+ 11px Tahoma,
130+ 'Pixelated MS Sans Serif',
131+ sans-serif;
132+ }
133+ .identity {
134+ display: flex;
135+ align-items: center;
136+ gap: 10px;
137+ }
138+ .buddy-icon {
139+ width: 48px;
140+ height: 48px;
141+ flex: 0 0 48px;
142+ display: grid;
143+ place-items: center;
144+ font-size: 28px;
145+ border: 1px solid #777;
146+ }
147+ .status {
148+ display: flex;
149+ align-items: center;
150+ gap: 4px;
151+ }
152+ .away-message {
153+ border: 1px solid #d0c7a0;
154+ background: #fff9dc;
155+ padding: 6px;
156+ white-space: pre-wrap;
157+ }
158+ .sunken-panel {
159+ background: white;
160+ padding: 5px;
161+ box-sizing: border-box;
162+ width: 100%;
163+ min-height: 65px;
164+ font: inherit;
165+ }
166+ .profile-text {
167+ flex: 1;
168+ overflow: auto;
169+ white-space: pre-wrap;
170+ overflow-wrap: anywhere;
171+ }
172+ textarea {
173+ resize: vertical;
174+ }
175+ .icon-grid {
176+ display: grid;
177+ grid-template-columns: repeat(8, 1fr);
178+ gap: 3px;
179+ }
180+ .icon-grid button {
181+ padding: 0;
182+ min-width: 0;
183+ height: 30px;
184+ font-size: 18px;
185+ cursor: pointer;
186+ border: 2px solid transparent;
187+ }
188+ .icon-grid button.selected {
189+ border-color: #245edb;
190+ }
191+ .actions {
192+ margin-top: auto;
193+ display: flex;
194+ justify-content: flex-end;
195+ align-items: center;
196+ gap: 6px;
197+ }
198+ .actions span[role='alert'] {
199+ color: #a00;
200+ }
201+</style>
A src/lib/components/desktop/sign-on-window.svelte +481 -0
@@ -0,0 +1,481 @@
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';
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+ function wait(ms: number) {
62+ return new Promise((r) => setTimeout(r, ms));
63+ }
64+
65+ async function login(name: string, pass: string): Promise<{ ok: boolean; error?: string }> {
66+ const res = await fetch('/api/session/login', {
67+ method: 'POST',
68+ headers: { 'Content-Type': 'application/json' },
69+ body: JSON.stringify({ username: name, password: pass })
70+ });
71+ const data = await res.json();
72+ if (!res.ok) return { ok: false, error: data.error || 'Connexion impossible.' };
73+ chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
74+ await invalidate('app:session');
75+ await invalidate('app:chat');
76+ return { ok: true };
77+ }
78+
79+ async function runSignOn(name: string, pass: string) {
80+ unlockAudio();
81+ error = '';
82+ step = 1;
83+ desktop.signingOn = true;
84+ playSound('modem');
85+ const modemStart = Date.now();
86+ try {
87+ await wait(900);
88+ step = 2;
89+ const result = await login(name, pass);
90+ if (!result.ok) {
91+ error = result.error ?? 'Connexion impossible.';
92+ step = 0;
93+ return;
94+ }
95+ if (rememberName) localStorage.setItem(REMEMBER_KEY, name);
96+ else localStorage.removeItem(REMEMBER_KEY);
97+ step = 3;
98+ // Let the modem "handshake" finish before the welcome chime
99+ const remaining = SOUND_DURATIONS_MS.modem - (Date.now() - modemStart);
100+ await wait(Math.max(400, Math.min(remaining, 2500)));
101+ playSound('welcome');
102+ signedOnAs = name;
103+ desktop.openBuddyList();
104+ await wait(1200);
105+ desktop.close(WINDOW_ID);
106+ } finally {
107+ desktop.signingOn = false;
108+ }
109+ }
110+
111+ async function handleSignin(e: Event) {
112+ e.preventDefault();
113+ const parsed = loginSchema.safeParse({ username: username.trim(), password: password.trim() });
114+ if (!parsed.success) {
115+ error = parsed.error.issues[0]?.message ?? 'Saisie invalide';
116+ return;
117+ }
118+ try {
119+ await runSignOn(username.trim(), password.trim());
120+ } catch (err) {
121+ console.debug('Sign-on error', err);
122+ error = 'Erreur de connexion. Réessaie.';
123+ step = 0;
124+ }
125+ }
126+
127+ async function handleSignup(e: Event) {
128+ e.preventDefault();
129+ signupError = '';
130+ const schema = createRegistrationSchema(DEFAULT_PASSWORD_CONSTRAINTS);
131+ try {
132+ schema.parse({
133+ suUsername: suUsername.trim(),
134+ suPassword: suPassword.trim(),
135+ suConfirmPassword: suConfirmPassword.trim(),
136+ captchaAnswer: captchaAnswer.trim(),
137+ turnstileToken: ''
138+ });
139+ } catch (err) {
140+ signupError =
141+ err instanceof z.ZodError
142+ ? (err.issues[0]?.message ?? 'Saisie invalide')
143+ : 'Saisie invalide';
144+ return;
145+ }
146+ signupBusy = true;
147+ try {
148+ const res = await fetch('/api/register', {
149+ method: 'POST',
150+ headers: { 'Content-Type': 'application/json' },
151+ body: JSON.stringify({ suUsername, suPassword, suConfirmPassword, captchaAnswer })
152+ });
153+ const data = (await res.json()) as RegisterResponse;
154+ if (!res.ok) {
155+ signupError = (data as RegisterResponseError).error || 'Inscription impossible.';
156+ return;
157+ }
158+ username = suUsername.trim();
159+ password = suPassword.trim();
160+ activeTab = 'signin';
161+ await runSignOn(username, password);
162+ } catch (err) {
163+ console.debug('Signup error', err);
164+ signupError = "Erreur d'inscription. Réessaie.";
165+ } finally {
166+ signupBusy = false;
167+ }
168+ }
169+
170+ function cancelSignOn() {
171+ step = 0;
172+ desktop.signingOn = false;
173+ }
174+</script>
175+
176+<XpWindow
177+ id={WINDOW_ID}
178+ width={300}
179+ height={activeTab === 'signin' ? 400 : 520}
180+ resizableWindow={false}
181+>
182+ <div class="signon">
183+ <div class="banner">
184+ <img src="/aim/running-man.svg" alt="" width="40" height="40" />
185+ <div class="brand">
186+ <div class="brand-title">PDR AIM</div>
187+ <div class="brand-sub">Instant Messenger</div>
188+ </div>
189+ </div>
190+
191+ {#if signedOnAs}
192+ <div class="progress">
193+ <p class="welcome">Bienvenue, <b>{signedOnAs}</b> !</p>
194+ <p class="hint">Ouverture de la liste de contacts…</p>
195+ </div>
196+ {:else if step > 0}
197+ <div class="progress">
198+ <p class="step-title">Connexion à PDR AIM</p>
199+ <ol class="steps">
200+ {#each [1, 2, 3] as n (n)}
201+ <li class:done={step > n} class:active={step === n}>{STEP_LABELS[n]}</li>
202+ {/each}
203+ </ol>
204+ <div class="bar"><div class="bar-fill" style="width: {step * 33}%"></div></div>
205+ <button type="button" onclick={cancelSignOn}>Annuler</button>
206+ </div>
207+ {:else}
208+ <div class="tabs">
209+ <button
210+ type="button"
211+ class:active={activeTab === 'signin'}
212+ onclick={() => (activeTab = 'signin')}>Connexion</button
213+ >
214+ <button
215+ type="button"
216+ class:active={activeTab === 'signup'}
217+ onclick={() => (activeTab = 'signup')}>Inscription</button
218+ >
219+ </div>
220+
221+ {#if activeTab === 'signin'}
222+ <form onsubmit={handleSignin}>
223+ <div class="field-row-stacked">
224+ <label for="so-name">Pseudo</label>
225+ <input id="so-name" type="text" bind:value={username} autocomplete="username" />
226+ </div>
227+ <div class="field-row-stacked">
228+ <label for="so-pass">Mot de passe</label>
229+ <input
230+ id="so-pass"
231+ type="password"
232+ bind:value={password}
233+ autocomplete="current-password"
234+ />
235+ </div>
236+ <div class="field-row">
237+ <input id="so-remember" type="checkbox" bind:checked={rememberName} />
238+ <label for="so-remember">Se souvenir du pseudo</label>
239+ </div>
240+ {#if error}
241+ <div class="error">{error}</div>
242+ {/if}
243+ <div class="actions">
244+ <button type="submit" class="primary">Se connecter</button>
245+ </div>
246+ <p class="hint">
247+ Pas encore de compte ?
248+ <button type="button" class="link" onclick={() => (activeTab = 'signup')}
249+ >Inscris-toi</button
250+ >
251+ </p>
252+ </form>
253+ {:else}
254+ <form onsubmit={handleSignup}>
255+ <div class="field-row-stacked">
256+ <label for="su-name">Pseudo</label>
257+ <input id="su-name" type="text" bind:value={suUsername} autocomplete="username" />
258+ </div>
259+ <div class="field-row-stacked">
260+ <label for="su-pass">Mot de passe</label>
261+ <input
262+ id="su-pass"
263+ type="password"
264+ bind:value={suPassword}
265+ autocomplete="new-password"
266+ onfocus={() => (showStrength = true)}
267+ />
268+ {#if showStrength}
269+ <PasswordStrengthIndicator password={suPassword} showDetails={true} />
270+ {/if}
271+ </div>
272+ <div class="field-row-stacked">
273+ <label for="su-confirm">Confirmer le mot de passe</label>
274+ <input
275+ id="su-confirm"
276+ type="password"
277+ bind:value={suConfirmPassword}
278+ autocomplete="new-password"
279+ />
280+ </div>
281+ <div class="field-row-stacked">
282+ <label for="su-captcha">Que signifie PDR ?</label>
283+ <input id="su-captcha" type="text" bind:value={captchaAnswer} />
284+ </div>
285+ {#if signupError}
286+ <div class="error">{signupError}</div>
287+ {/if}
288+ <div class="actions">
289+ <button type="submit" class="primary" disabled={signupBusy}>
290+ {signupBusy ? 'Inscription…' : "S'inscrire"}
291+ </button>
292+ </div>
293+ </form>
294+ {/if}
295+ {/if}
296+ </div>
297+</XpWindow>
298+
299+<style>
300+ .signon {
301+ display: flex;
302+ flex-direction: column;
303+ gap: 8px;
304+ font-size: 11px;
305+ height: 100%;
306+ overflow-y: auto;
307+ }
308+
309+ .banner {
310+ display: flex;
311+ align-items: center;
312+ gap: 10px;
313+ padding: 8px 10px;
314+ background: linear-gradient(180deg, #fff3a6 0%, #f6c700 100%);
315+ border: 1px solid #c99a00;
316+ }
317+
318+ .brand-title {
319+ font-weight: bold;
320+ font-size: 18px;
321+ color: #1b1b1b;
322+ letter-spacing: 1px;
323+ }
324+
325+ .brand-sub {
326+ font-size: 10px;
327+ color: #5a4a00;
328+ }
329+
330+ .tabs {
331+ display: flex;
332+ border-bottom: 1px solid #919b9c;
333+ }
334+
335+ .tabs button {
336+ flex: 1;
337+ border: 1px solid #919b9c;
338+ border-bottom: none;
339+ background: #ece9d8;
340+ box-shadow: none;
341+ border-radius: 3px 3px 0 0;
342+ min-width: 0;
343+ padding: 4px;
344+ font-size: 11px;
345+ }
346+
347+ .tabs button.active {
348+ background: #fff;
349+ font-weight: bold;
350+ position: relative;
351+ top: 1px;
352+ }
353+
354+ form {
355+ display: flex;
356+ flex-direction: column;
357+ gap: 8px;
358+ padding: 4px 2px;
359+ }
360+
361+ .field-row-stacked input[type='text'],
362+ .field-row-stacked input[type='password'] {
363+ width: 100%;
364+ }
365+
366+ .actions {
367+ display: flex;
368+ justify-content: center;
369+ margin-top: 4px;
370+ }
371+
372+ .primary {
373+ font-weight: bold;
374+ min-width: 120px;
375+ }
376+
377+ .error {
378+ color: #c00000;
379+ background: #fff0f0;
380+ border: 1px solid #e0a0a0;
381+ padding: 4px 6px;
382+ }
383+
384+ .hint {
385+ color: #555;
386+ text-align: center;
387+ margin: 4px 0 0;
388+ }
389+
390+ .link {
391+ background: none;
392+ border: none;
393+ box-shadow: none;
394+ color: #0000c0;
395+ text-decoration: underline;
396+ padding: 0;
397+ min-width: 0;
398+ font-size: 11px;
399+ cursor: pointer;
400+ }
401+
402+ .progress {
403+ display: flex;
404+ flex-direction: column;
405+ gap: 10px;
406+ padding: 10px 6px;
407+ align-items: center;
408+ }
409+
410+ .step-title {
411+ font-weight: bold;
412+ margin: 0;
413+ }
414+
415+ .steps {
416+ list-style: none;
417+ padding: 0;
418+ margin: 0;
419+ width: 100%;
420+ display: flex;
421+ flex-direction: column;
422+ gap: 4px;
423+ }
424+
425+ .steps li {
426+ color: #888;
427+ padding-left: 18px;
428+ position: relative;
429+ }
430+
431+ .steps li::before {
432+ content: '○';
433+ position: absolute;
434+ left: 2px;
435+ }
436+
437+ .steps li.active {
438+ color: #000;
439+ font-weight: bold;
440+ }
441+
442+ .steps li.active::before {
443+ content: '●';
444+ color: #f6c700;
445+ animation: blink 0.8s steps(2, start) infinite;
446+ }
447+
448+ .steps li.done {
449+ color: #2a7a2a;
450+ }
451+
452+ .steps li.done::before {
453+ content: '✓';
454+ color: #2a7a2a;
455+ }
456+
457+ .bar {
458+ width: 100%;
459+ height: 14px;
460+ border: 1px solid #919b9c;
461+ background: #fff;
462+ padding: 1px;
463+ }
464+
465+ .bar-fill {
466+ height: 100%;
467+ background: repeating-linear-gradient(90deg, #2c6ed5 0 8px, transparent 8px 10px);
468+ transition: width 0.4s ease;
469+ }
470+
471+ .welcome {
472+ font-size: 14px;
473+ margin: 8px 0 0;
474+ }
475+
476+ @keyframes blink {
477+ to {
478+ visibility: hidden;
479+ }
480+ }
481+</style>
A src/lib/components/desktop/taskbar.svelte +273 -0
@@ -0,0 +1,273 @@
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';
6+
7+ let menuOpen = $state(false);
8+ let soundOn = $state(true);
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+ soundOn = soundsEnabled();
20+ updateClock();
21+ const timer = setInterval(updateClock, 30_000);
22+ function outside(event: PointerEvent) {
23+ if (
24+ menuOpen &&
25+ !menuElement?.contains(event.target as Node) &&
26+ !startElement?.contains(event.target as Node)
27+ )
28+ menuOpen = false;
29+ }
30+ function escape(event: KeyboardEvent) {
31+ if (event.key === 'Escape') menuOpen = false;
32+ }
33+ document.addEventListener('pointerdown', outside);
34+ document.addEventListener('keydown', escape);
35+ return () => {
36+ clearInterval(timer);
37+ document.removeEventListener('pointerdown', outside);
38+ document.removeEventListener('keydown', escape);
39+ };
40+ });
41+
42+ function choose(action: () => void) {
43+ menuOpen = false;
44+ action();
45+ }
46+
47+ async function logout() {
48+ const response = await fetch('/api/session/logout', { method: 'POST' });
49+ if (response.ok) window.location.reload();
50+ }
51+</script>
52+
53+<div class="taskbar">
54+ <button
55+ class="start"
56+ bind:this={startElement}
57+ aria-expanded={menuOpen}
58+ aria-controls="start-menu"
59+ onclick={() => (menuOpen = !menuOpen)}
60+ ><img src="/aim/start.svg" alt="" width="20" height="20" />démarrer</button
61+ >
62+ {#if menuOpen}
63+ <div class="start-menu" id="start-menu" bind:this={menuElement}>
64+ <div class="menu-header">{user?.nickname ?? 'Invité'}</div>
65+ <button onclick={() => choose(() => desktop.openBuddyList())}
66+ >PDR AIM — Liste de contacts</button
67+ >
68+ <button onclick={() => choose(() => desktop.openChatRoom(null, 'General'))}
69+ >Salon General</button
70+ >
71+ <button onclick={() => choose(() => desktop.openNewRoom())}>Nouveau salon…</button>
72+ <hr />
73+ <button onclick={() => choose(() => desktop.openPreferences())}>Préférences</button>
74+ <button onclick={() => choose(() => desktop.openAbout())}>À propos</button>
75+ <hr />
76+ {#if user}
77+ <button
78+ onclick={() => {
79+ menuOpen = false;
80+ void logout();
81+ }}>Se déconnecter</button
82+ >
83+ {:else}
84+ <button onclick={() => choose(() => desktop.openLogin('signin'))}>Se connecter</button>
85+ <button onclick={() => choose(() => desktop.openLogin('signup'))}>S'inscrire</button>
86+ {/if}
87+ </div>
88+ {/if}
89+ <div class="tasks">
90+ {#each desktop.taskbarWindows as w (w.id)}
91+ <button
92+ class="task"
93+ class:pressed={desktop.focusedId === w.id}
94+ class:minimized={w.minimized}
95+ class:attention={w.attention}
96+ title={w.title}
97+ onclick={() => desktop.toggleFromTaskbar(w.id)}
98+ ><img src={w.icon} alt="" width="16" height="16" /><span>{w.title}</span></button
99+ >
100+ {/each}
101+ </div>
102+ <div class="tray">
103+ <button
104+ class="tray-button"
105+ title={soundOn ? 'Désactiver les sons' : 'Activer les sons'}
106+ aria-label={soundOn ? 'Désactiver les sons' : 'Activer les sons'}
107+ onclick={() => {
108+ unlockAudio();
109+ soundOn = !soundsEnabled();
110+ setSoundsEnabled(soundOn);
111+ }}><span aria-hidden="true">{soundOn ? '🔊' : '🔇'}</span></button
112+ >
113+ <img
114+ src="/aim/running-man.svg"
115+ alt=""
116+ title={user ? `Statut : ${user.status}` : 'Hors ligne'}
117+ width="16"
118+ height="16"
119+ />
120+ <time>{clock}</time>
121+ </div>
122+</div>
123+
124+<style>
125+ .taskbar {
126+ position: fixed;
127+ bottom: 0;
128+ left: 0;
129+ right: 0;
130+ height: 30px;
131+ z-index: 5000;
132+ display: flex;
133+ align-items: stretch;
134+ gap: 4px;
135+ background: linear-gradient(#245edb, #3168d5);
136+ border-top: 1px solid #0f3d9a;
137+ box-sizing: border-box;
138+ color: white;
139+ font:
140+ 11px Tahoma,
141+ 'Pixelated MS Sans Serif',
142+ sans-serif;
143+ }
144+ .start {
145+ display: flex;
146+ align-items: center;
147+ gap: 4px;
148+ border: 0;
149+ border-radius: 0 14px 14px 0;
150+ background: linear-gradient(#3c8f3c, #2c7a2c);
151+ color: white;
152+ font:
153+ bold italic 14px Tahoma,
154+ sans-serif;
155+ padding: 0 14px 0 6px;
156+ cursor: pointer;
157+ box-shadow: inset 0 1px #8acb70;
158+ }
159+ .start-menu {
160+ position: absolute;
161+ bottom: 30px;
162+ left: 0;
163+ width: 250px;
164+ background: white;
165+ border: 2px solid #245edb;
166+ box-shadow: 2px 2px 8px #3339;
167+ color: #222;
168+ padding-bottom: 4px;
169+ }
170+ .menu-header {
171+ background: linear-gradient(#245edb, #4f91ef);
172+ color: white;
173+ font-weight: bold;
174+ padding: 10px;
175+ }
176+ .start-menu button {
177+ display: block;
178+ width: 100%;
179+ text-align: left;
180+ background: transparent;
181+ border: 0;
182+ box-shadow: none;
183+ border-radius: 0;
184+ padding: 6px 12px;
185+ font: inherit;
186+ color: #222;
187+ cursor: pointer;
188+ }
189+ .start-menu button:hover,
190+ .start-menu button:focus-visible {
191+ background: #316ac5;
192+ color: white;
193+ }
194+ .start-menu hr {
195+ border: 0;
196+ border-top: 1px solid #ccc;
197+ margin: 4px 8px;
198+ }
199+ .tasks {
200+ display: flex;
201+ gap: 3px;
202+ align-items: center;
203+ flex: 1;
204+ overflow: hidden;
205+ min-width: 0;
206+ }
207+ .task {
208+ display: flex;
209+ align-items: center;
210+ gap: 4px;
211+ width: 160px;
212+ max-width: 160px;
213+ min-width: 36px;
214+ height: 24px;
215+ flex-shrink: 1;
216+ color: white;
217+ background: #ffffff30;
218+ border: 1px solid #ffffff44;
219+ border-radius: 3px;
220+ padding: 2px 6px;
221+ font: inherit;
222+ text-align: left;
223+ cursor: pointer;
224+ }
225+ .task span {
226+ overflow: hidden;
227+ text-overflow: ellipsis;
228+ white-space: nowrap;
229+ }
230+ .task img {
231+ flex: 0 0 16px;
232+ }
233+ .task.pressed {
234+ background: #103b96;
235+ box-shadow: inset 1px 1px 3px #082a76;
236+ }
237+ .task.minimized {
238+ background: #ffffff18;
239+ }
240+ .task.attention {
241+ animation: flash 1s infinite alternate;
242+ }
243+ @keyframes flash {
244+ from {
245+ background: #e69a12;
246+ }
247+ to {
248+ background: #ffdc55;
249+ color: #222;
250+ }
251+ }
252+ .tray {
253+ display: flex;
254+ align-items: center;
255+ gap: 8px;
256+ padding: 0 8px;
257+ background: linear-gradient(#0c91e8, #1684d1);
258+ border-left: 1px solid #78baff;
259+ white-space: nowrap;
260+ }
261+ .tray-button {
262+ min-width: 0;
263+ padding: 0;
264+ border: 0;
265+ box-shadow: none;
266+ background: none;
267+ cursor: pointer;
268+ font-size: 14px;
269+ }
270+ .tray time {
271+ font: inherit;
272+ }
273+</style>
M src/lib/components/desktop/xp-window.svelte +10 -5
@@ -43,7 +43,7 @@
43 const win = $derived(desktop.get(id));43 const win = $derived(desktop.get(id));
44 const focused = $derived(desktop.focusedId === id);44 const focused = $derived(desktop.focusedId === id);
45 45
46- // svelte-ignore state_referenced_locally -- initial geometry only; the user moves/resizes afterwards46+ // svelte-ignore state_referenced_locally
47 let w = $state(width);47 let w = $state(width);
48 // svelte-ignore state_referenced_locally48 // svelte-ignore state_referenced_locally
49 let h = $state(height);49 let h = $state(height);
@@ -136,9 +136,7 @@
136 <div class="title-bar-controls">136 <div class="title-bar-controls">
137 <button aria-label="Minimize" onclick={minimize}></button>137 <button aria-label="Minimize" onclick={minimize}></button>
138 {#if showMaximize && !isMobile}138 {#if showMaximize && !isMobile}
139- <button139+ <button aria-label={isMaximized ? 'Restore' : 'Maximize'} onclick={toggleMaximize}
140- aria-label={isMaximized ? 'Restore' : 'Maximize'}
141- onclick={toggleMaximize}
142 ></button>140 ></button>
143 {/if}141 {/if}
144 <button aria-label="Close" onclick={close}></button>142 <button aria-label="Close" onclick={close}></button>
@@ -174,7 +172,14 @@
174 /* xp.css already styles active/inactive gradients through :not(:focus-within);172 /* xp.css already styles active/inactive gradients through :not(:focus-within);
175 force the inactive look based on our own focus tracking instead. */173 force the inactive look based on our own focus tracking instead. */
176 .xp-window.inactive .title-bar {174 .xp-window.inactive .title-bar {
177- background: linear-gradient(180deg, #7697e7 0%, #7e9ee3 8%, #94afec 40%, #97b4e9 88%, #82a5e4 100%);175+ background: linear-gradient(
176+ 180deg,
177+ #7697e7 0%,
178+ #7e9ee3 8%,
179+ #94afec 40%,
180+ #97b4e9 88%,
181+ #82a5e4 100%
182+ );
178 }183 }
179 184
180 .title-bar {185 .title-bar {
M src/lib/types/chat.ts +2 -0
@@ -8,6 +8,8 @@ export interface User {
8 createdAt: number; // Timestamp (e.g., Unix timestamp)8 createdAt: number; // Timestamp (e.g., Unix timestamp)
9 lastSeen?: number | null; // Optional last active timestamp9 lastSeen?: number | null; // Optional last active timestamp
10 lastReadMentionTimestamp?: number | null; // Timestamp of last read mention10 lastReadMentionTimestamp?: number | null; // Timestamp of last read mention
11+ awayMessage?: string; // AIM away message (only exposed while away)
12+ buddyIcon?: string; // Preset buddy icon id
11 }13 }
12 14
13 export type UserStatus = 'offline' | 'online' | 'away' | string;15 export type UserStatus = 'offline' | 'online' | 'away' | string;
M src/routes/+page.svelte +84 -17
@@ -1,37 +1,104 @@
1 <script lang="ts">1 <script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { browser } from '$app/environment';
2 import ChatRoom from '$lib/components/chat-room.svelte';4 import ChatRoom from '$lib/components/chat-room.svelte';
3 import DesktopIcons from '$lib/components/desktop-icons.svelte';5 import DesktopIcons from '$lib/components/desktop-icons.svelte';
4- import AimLogin from '$lib/components/aim-login.svelte';6+ import Taskbar from '$lib/components/desktop/taskbar.svelte';
7+ import BuddyList from '$lib/components/desktop/buddy-list.svelte';
8+ import ImWindow from '$lib/components/desktop/im-window.svelte';
9+ import SignOnWindow from '$lib/components/desktop/sign-on-window.svelte';
10+ import ProfileWindow from '$lib/components/desktop/profile-window.svelte';
11+ import AwayWindow from '$lib/components/desktop/away-window.svelte';
12+ import NewRoomWindow from '$lib/components/desktop/new-room-window.svelte';
13+ import AboutWindow from '$lib/components/desktop/about-window.svelte';
14+ import PreferencesWindow from '$lib/components/desktop/preferences-window.svelte';
15+ import {
16+ desktop,
17+ type ChatRoomWindowProps,
18+ type ImWindowProps,
19+ type ProfileWindowProps
20+ } from '$lib/states/desktop.svelte';
21+ import { chatState } from '$lib/states/chat.svelte';
5 22
6 const props = $props();23 const props = $props();
7 const pageData = $derived(props.data);24 const pageData = $derived(props.data);
8- let showChatRoom = $state(true);
9- let showAuth = $state(false);
10 25
11- function handleLoginSuccess() {26+ const currentUser = $derived(chatState.getCurrentUser());
12- showAuth = false;27+
28+ // Initial desktop layout: the General room, plus the buddy list when signed in.
29+ onMount(() => {
30+ if (!browser) return;
31+ desktop.openChatRoom(null, 'General');
32+ if (pageData.user) {
33+ desktop.openBuddyList();
34+ } else {
35+ desktop.openLogin('signin');
36+ }
37+ desktop.focus('room-default');
38+ });
39+
40+ // When the session appears (login) make sure the buddy list is around;
41+ // when it disappears (logout) close private windows.
42+ let lastUserId: string | null = null;
43+ $effect(() => {
44+ const id = currentUser?.id ?? null;
45+ if (id === lastUserId) return;
46+ lastUserId = id;
47+ if (id) {
48+ if (!desktop.has('buddy-list')) desktop.openBuddyList();
49+ } else {
50+ for (const w of desktop.windows) {
51+ if (w.kind === 'im' || w.kind === 'buddy-list' || w.kind === 'away') desktop.close(w.id);
52+ }
53+ }
54+ });
55+
56+ function onDesktopClick(e: MouseEvent) {
57+ if (e.target === e.currentTarget) desktop.blurAll();
13 }58 }
14 </script>59 </script>
15 60
16-<div class="desktop content-wrapper">61+<div
17- <DesktopIcons bind:showChatRoom bind:showAuth />62+ class="desktop"
63+ class:signing-on={desktop.signingOn}
64+ onmousedown={onDesktopClick}
65+ role="presentation"
66+>
67+ <DesktopIcons />
18 68
19- {#if showChatRoom}69+ {#each desktop.windows as win (win.id)}
20- <ChatRoom bind:showChatRoom initialTextStyle={pageData.textStyle} />70+ {#if win.kind === 'chat-room'}
21- {/if}71+ {@const p = win.props as unknown as ChatRoomWindowProps}
72+ <ChatRoom roomId={p.roomId} roomName={p.roomName} initialTextStyle={pageData.textStyle} />
73+ {:else if win.kind === 'buddy-list'}
74+ <BuddyList />
75+ {:else if win.kind === 'im'}
76+ {@const p = win.props as unknown as ImWindowProps}
77+ <ImWindow roomId={p.roomId} otherUserId={p.otherUserId} otherNickname={p.otherNickname} />
78+ {:else if win.kind === 'login'}
79+ <SignOnWindow tab={(win.props.tab as 'signin' | 'signup') ?? 'signin'} />
80+ {:else if win.kind === 'profile'}
81+ {@const p = win.props as unknown as ProfileWindowProps}
82+ <ProfileWindow userId={p.userId} nickname={p.nickname} />
83+ {:else if win.kind === 'away'}
84+ <AwayWindow />
85+ {:else if win.kind === 'new-room'}
86+ <NewRoomWindow />
87+ {:else if win.kind === 'about'}
88+ <AboutWindow />
89+ {:else if win.kind === 'preferences'}
90+ <PreferencesWindow />
91+ {/if}
92+ {/each}
22 93
23- {#if showAuth}94+ <Taskbar />
24- <AimLogin bind:showAuth onLoginSuccess={handleLoginSuccess} />
25- {/if}
26 </div>95 </div>
27 96
28 <style>97 <style>
29- .content-wrapper {98+ .desktop {
30 position: absolute;99 position: absolute;
31 inset: 0;100 inset: 0;
32- display: flex;
33- align-items: center;
34- justify-content: center;
35 box-sizing: border-box;101 box-sizing: border-box;
102+ overflow: hidden;
36 }103 }
37 </style>104 </style>
A src/routes/api/session/logout/+server.ts +24 -0
@@ -0,0 +1,24 @@
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+export const POST: RequestHandler = async ({ cookies, locals }) => {
10+ if (locals.session) {
11+ try {
12+ await users.updateStatus(locals.session.userId, 'offline');
13+ await invalidateSession(locals.session.id);
14+ } catch (err) {
15+ log.error('Logout cleanup failed', {
16+ error: err instanceof Error ? err.message : 'Unknown error'
17+ });
18+ }
19+ }
20+ deleteSessionTokenCookie({ cookies });
21+ return new Response(JSON.stringify({ success: true }), {
22+ headers: { 'Content-Type': 'application/json' }
23+ });
24+};
A static/sounds/README.md +3 -0
@@ -0,0 +1,3 @@
1+# Drop your own AIM samples here (doorOpen, doorSlam, imReceive, imSend, welcome, modem) as .wav/.mp3/.ogg.
2+
3+# They are ignored by git; without them the app synthesizes the sounds.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close