1<script lang="ts">
2 import { onMount } from 'svelte';
3 import { SvelteSet, SvelteMap } from 'svelte/reactivity';
4 import { useQuery } from 'convex-svelte';
5 import { api } from '../../../convex/_generated/api';
6 import type { Id } from '../../../convex/_generated/dataModel';
7 import type { Buddy, AimStatus } from '$lib/types/aim';
8 import { DEFAULT_BUDDY_GROUPS, STATUS_LABELS_FR } from '$lib/types/aim';
9 import { playSound } from '$lib/aim/sounds.svelte';
10 import { authQuery, useAimClient, convexAvailable } from '$lib/aim/client';
11 import { chatState } from '$lib/states/chat.svelte';
12 import { desktop } from '$lib/states/desktop.svelte';
13 import XpWindow from './xp-window.svelte';
14 import { browser } from '$app/environment';
15
16 // Dock top-right like the real AIM buddy list, with breathing room above and
17 // above the taskbar (30px) below.
18 const BUDDY_LIST_WIDTH = 230;
19 const buddyListHeight = browser
20 ? Math.max(360, Math.min(520, window.innerHeight - 30 - 2 * 40))
21 : 520;
22
23 const aim = useAimClient();
24 const buddyQuery = authQuery(api.aim.getMyBuddies, () => ({}));
25 const directQuery = authQuery(api.aim.getMyDirectRooms, () => ({}));
26 const meQuery = authQuery(api.aim.getMe, () => ({}));
27 const roomsQuery = convexAvailable ? useQuery(api.aim.getGroupRooms, {}) : null;
28 const usersQuery = convexAvailable ? useQuery(api.queries.getUsersPublic, {}) : null;
29 const me = $derived(chatState.getCurrentUser());
30 const buddies = $derived(buddyQuery?.data ?? []);
31 const collapsed = new SvelteSet<string>();
32 const flashing = new SvelteSet<string>();
33 let selectedId = $state<Id<'users'> | null>(null);
34 let editMode = $state(false);
35 let panel = $state<'im' | 'add' | null>(null);
36 let nickname = $state('');
37 let addId = $state('');
38 let addGroup = $state<string>('Buddies');
39 let customGroup = $state('');
40 let error = $state('');
41 let dropdownMenu = $state<'aim' | 'contacts' | 'help' | null>(null);
42 let context = $state<{ buddy: Buddy; x: number; y: number } | null>(null);
43 let menuBar: HTMLDivElement;
44 let contextElement = $state<HTMLDivElement>();
45 let previous = new SvelteMap<string, AimStatus>();
46 let initialized = false;
47 const timers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
48
49 const sortedGroups = $derived.by(() => {
50 const groups = new SvelteMap<string, Buddy[]>();
51 for (const name of DEFAULT_BUDDY_GROUPS) groups.set(name, []);
52 for (const buddy of buddies) {
53 const name = buddy.group || 'Buddies';
54 if (!groups.has(name)) groups.set(name, []);
55 if (buddy.status !== 'offline') groups.get(name)!.push(buddy);
56 }
57 const rank: Record<AimStatus, number> = { online: 0, away: 1, idle: 2, busy: 3, offline: 4 };
58 return [...groups.entries()]
59 .sort(([a], [b]) => {
60 const ai = DEFAULT_BUDDY_GROUPS.indexOf(a as (typeof DEFAULT_BUDDY_GROUPS)[number]);
61 const bi = DEFAULT_BUDDY_GROUPS.indexOf(b as (typeof DEFAULT_BUDDY_GROUPS)[number]);
62 return ai !== -1 && bi !== -1
63 ? ai - bi
64 : ai !== -1
65 ? -1
66 : bi !== -1
67 ? 1
68 : a.localeCompare(b);
69 })
70 .map(([name, members]) => ({
71 name,
72 members: members.sort(
73 (a, b) => rank[a.status] - rank[b.status] || a.nickname.localeCompare(b.nickname)
74 ),
75 total: buddies.filter((b) => (b.group || 'Buddies') === name).length
76 }));
77 });
78 const offline = $derived(
79 buddies
80 .filter((b) => b.status === 'offline')
81 .sort((a, b) => a.nickname.localeCompare(b.nickname))
82 );
83 const availableUsers = $derived(
84 (usersQuery?.data ?? [])
85 .filter((u) => u.id !== me?.id && !buddies.some((b) => b.id === u.id))
86 .sort((a, b) => a.nickname.localeCompare(b.nickname))
87 );
88 const selectedBuddy = $derived(buddies.find((b) => b.id === selectedId));
89
90 onMount(() => {
91 try {
92 const stored = JSON.parse(localStorage.getItem('pdraim-buddylist-collapsed') ?? '[]');
93 if (Array.isArray(stored))
94 for (const name of stored) if (typeof name === 'string') collapsed.add(name);
95 } catch {
96 /* Ignore malformed saved preferences. */
97 }
98 return () => {
99 for (const timer of timers.values()) clearTimeout(timer);
100 };
101 });
102
103 $effect(() => {
104 const data = buddyQuery?.data;
105 if (!me || !data) {
106 previous = new SvelteMap<string, AimStatus>();
107 initialized = false;
108 return;
109 }
110 const next = new SvelteMap<string, AimStatus>();
111 for (const buddy of data) {
112 next.set(buddy.id, buddy.status);
113 const old = previous.get(buddy.id);
114 if (!initialized || old === undefined || old === buddy.status) continue;
115 if (old === 'offline' && buddy.status !== 'offline') {
116 playSound('doorOpen');
117 flashing.add(buddy.id);
118 const existing = timers.get(buddy.id);
119 if (existing) clearTimeout(existing);
120 timers.set(
121 buddy.id,
122 setTimeout(() => {
123 flashing.delete(buddy.id);
124 timers.delete(buddy.id);
125 }, 1500)
126 );
127 } else if (old !== 'offline' && buddy.status === 'offline') playSound('doorSlam');
128 }
129 previous = next;
130 initialized = true;
131 });
132
133 function toggleGroup(name: string) {
134 if (collapsed.has(name)) collapsed.delete(name);
135 else collapsed.add(name);
136 localStorage.setItem('pdraim-buddylist-collapsed', JSON.stringify([...collapsed]));
137 }
138 function openPanel(which: 'im' | 'add') {
139 panel = panel === which ? null : which;
140 error = '';
141 dropdownMenu = null;
142 }
143 async function openIm(id: Id<'users'>, name: string) {
144 try {
145 const room = await aim.getOrCreateDirectRoom(id);
146 desktop.openIm({ roomId: room.id, otherUserId: id, otherNickname: name });
147 panel = null;
148 } catch {
149 error = 'Impossible d’ouvrir la conversation.';
150 }
151 }
152 async function startIm() {
153 const user = usersQuery?.data?.find(
154 (u) => u.nickname.toLocaleLowerCase() === nickname.trim().toLocaleLowerCase()
155 );
156 if (!user) {
157 error = 'Pseudo introuvable.';
158 return;
159 }
160 await openIm(user.id, user.nickname);
161 }
162 async function addBuddy() {
163 const user = availableUsers.find((u) => u.id === addId);
164 const group = addGroup === 'Autre…' ? customGroup.trim() : addGroup;
165 if (!user || !group) {
166 error = 'Choisissez un contact et un groupe.';
167 return;
168 }
169 try {
170 await aim.addBuddy(user.id, group);
171 panel = null;
172 addId = '';
173 error = '';
174 } catch {
175 error = 'Impossible d’ajouter ce contact.';
176 }
177 }
178 async function removeBuddy(id: Id<'users'>) {
179 context = null;
180 try {
181 await aim.removeBuddy(id);
182 if (selectedId === id) selectedId = null;
183 } catch {
184 error = 'Impossible de retirer ce contact.';
185 }
186 }
187 function showContext(event: MouseEvent, buddy: Buddy) {
188 event.preventDefault();
189 selectedId = buddy.id;
190 dropdownMenu = null;
191 context = {
192 buddy,
193 x: Math.min(event.clientX, window.innerWidth - 190),
194 y: Math.min(event.clientY, window.innerHeight - 95)
195 };
196 }
197 function outside(event: MouseEvent) {
198 const target = event.target as Node;
199 if (dropdownMenu && !menuBar?.contains(target)) dropdownMenu = null;
200 if (context && !contextElement?.contains(target)) context = null;
201 }
202 function escape(event: KeyboardEvent) {
203 if (event.key === 'Escape') {
204 dropdownMenu = null;
205 context = null;
206 panel = null;
207 }
208 }
209 async function logout() {
210 dropdownMenu = null;
211 try {
212 const response = await fetch('/api/session/logout', { method: 'POST' });
213 if (!response.ok) throw new Error('logout failed');
214 window.location.reload();
215 } catch {
216 error = 'Déconnexion impossible.';
217 }
218 }
219 function tooltip(buddy: Buddy) {
220 return `${STATUS_LABELS_FR[buddy.status]}${buddy.status === 'offline' && buddy.lastSeen ? ` — Dernière visite : ${new Date(buddy.lastSeen).toLocaleString('fr-FR')}` : ''}`;
221 }
222</script>
223
224<svelte:window onclick={outside} onkeydown={escape} />
225
226{#snippet buddyRow(buddy: Buddy)}
227 <div class="row" class:selected={selectedId === buddy.id} class:flash={flashing.has(buddy.id)}>
228 <button
229 class="row-action"
230 class:offline={buddy.status === 'offline'}
231 class:away={buddy.status === 'away'}
232 title={tooltip(buddy)}
233 onclick={() => (selectedId = buddy.id)}
234 ondblclick={() => openIm(buddy.id, buddy.nickname)}
235 oncontextmenu={(e) => showContext(e, buddy)}
236 >
237 <img src={`/aim/status-${buddy.status}.svg`} alt="" width="12" height="12" />
238 <span class="truncate">{buddy.nickname}{buddy.status === 'idle' ? ' (Inactif)' : ''}</span>
239 {#if buddy.status === 'away'}<img
240 src="/aim/xp-note-16.png"
241 alt=""
242 width="12"
243 height="12"
244 />{/if}
245 </button>
246 {#if editMode}<button
247 class="remove"
248 aria-label={`Retirer ${buddy.nickname}`}
249 title={`Retirer ${buddy.nickname}`}
250 onclick={() => removeBuddy(buddy.id)}>✕</button
251 >{/if}
252 </div>
253{/snippet}
254
255<XpWindow
256 id="buddy-list"
257 width={BUDDY_LIST_WIDTH}
258 height={buddyListHeight}
259 minWidth={BUDDY_LIST_WIDTH}
260 showMaximize={false}
261 x={browser ? Math.max(8, window.innerWidth - BUDDY_LIST_WIDTH - 24) : undefined}
262 y={browser ? 40 : undefined}
263>
264 {#snippet menu()}
265 <div class="menus" bind:this={menuBar}>
266 <div class="menu-wrap">
267 <button
268 aria-expanded={dropdownMenu === 'aim'}
269 onclick={() => (dropdownMenu = dropdownMenu === 'aim' ? null : 'aim')}>Mon AIM</button
270 >
271 {#if dropdownMenu === 'aim'}<div class="dropdown">
272 <button
273 onclick={() => {
274 dropdownMenu = null;
275 desktop.openAway();
276 }}>Message d’absence…</button
277 >
278 <button
279 disabled={!me}
280 onclick={() => {
281 dropdownMenu = null;
282 if (me)
283 desktop.openProfile({ userId: me.id as Id<'users'>, nickname: me.nickname });
284 }}>Modifier mon profil…</button
285 >
286 <button
287 onclick={() => {
288 dropdownMenu = null;
289 desktop.openPreferences();
290 }}>Préférences…</button
291 >
292 <hr />
293 <button onclick={logout}>Se déconnecter</button>
294 </div>{/if}
295 </div>
296 <div class="menu-wrap">
297 <button
298 aria-expanded={dropdownMenu === 'contacts'}
299 onclick={() => (dropdownMenu = dropdownMenu === 'contacts' ? null : 'contacts')}
300 >Contacts</button
301 >
302 {#if dropdownMenu === 'contacts'}<div class="dropdown">
303 <button onclick={() => openPanel('add')}>Ajouter un contact…</button><button
304 onclick={() => {
305 dropdownMenu = null;
306 desktop.openNewRoom();
307 }}>Nouveau salon…</button
308 >
309 </div>{/if}
310 </div>
311 <div class="menu-wrap">
312 <button
313 aria-expanded={dropdownMenu === 'help'}
314 onclick={() => (dropdownMenu = dropdownMenu === 'help' ? null : 'help')}>Aide</button
315 >
316 {#if dropdownMenu === 'help'}<div class="dropdown">
317 <button
318 onclick={() => {
319 dropdownMenu = null;
320 desktop.openAbout();
321 }}>À propos de PDR AIM</button
322 >
323 </div>{/if}
324 </div>
325 </div>
326 {/snippet}
327 <div class="buddy-content">
328 <header class="identity">
329 {#if me}
330 <img class="icon" src="/aim/running-man-32.png" alt="" width="32" height="32" />
331 <div class="identity-text">
332 <strong>{me.nickname}</strong><span title={meQuery?.data?.awayMessage ?? ''}
333 >{meQuery?.data?.status === 'away'
334 ? `Absent${meQuery.data.awayMessage ? ` : ${meQuery.data.awayMessage}` : ''}`
335 : (STATUS_LABELS_FR[(meQuery?.data?.status ?? me.status) as AimStatus] ??
336 'En ligne')}</span
337 >
338 </div>
339 {:else}
340 <div>
341 <button onclick={() => desktop.openLogin('signin')}>Se connecter</button>
342 <p>Connectez-vous pour voir vos contacts.</p>
343 </div>
344 {/if}
345 </header>
346 <div class="tabs">
347 <button class:active={!editMode} aria-pressed={!editMode} onclick={() => (editMode = false)}
348 >Online</button
349 ><button class:active={editMode} aria-pressed={editMode} onclick={() => (editMode = true)}
350 >List Setup</button
351 >
352 </div>
353 <div class="sunken-panel tree">
354 {#if me}
355 {#each sortedGroups as group (group.name)}
356 <button
357 class="group"
358 aria-expanded={!collapsed.has(group.name)}
359 onclick={() => toggleGroup(group.name)}
360 >{collapsed.has(group.name) ? '▶' : '▼'}
361 {group.name} ({group.members.filter((buddy) => buddy.status === 'online')
362 .length}/{group.total})</button
363 >
364 {#if !collapsed.has(group.name)}{#each group.members as buddy (buddy.id)}{@render buddyRow(
365 buddy
366 )}{/each}{/if}
367 {/each}
368 <button
369 class="group"
370 aria-expanded={!collapsed.has('Offline')}
371 onclick={() => toggleGroup('Offline')}
372 >{collapsed.has('Offline') ? '▶' : '▼'} Offline ({offline.length})</button
373 >
374 {#if !collapsed.has('Offline')}{#each offline as buddy (buddy.id)}{@render buddyRow(
375 buddy
376 )}{/each}{/if}
377 <button
378 class="group"
379 aria-expanded={!collapsed.has('Salons')}
380 onclick={() => toggleGroup('Salons')}
381 >{collapsed.has('Salons') ? '▶' : '▼'} Salons ({roomsQuery?.data?.length ?? 0})</button
382 >
383 {#if !collapsed.has('Salons')}{#each [...(roomsQuery?.data ?? [])].sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name)) as room (room.id)}<button
384 class="list-entry"
385 onclick={() => desktop.openChatRoom(room.id, room.name)}
386 ><img src="/aim/chat-room-16.png" alt="" width="12" height="12" />
387 <span class="truncate">{room.name}</span></button
388 >{/each}{/if}
389 <button
390 class="group"
391 aria-expanded={!collapsed.has('Conversations')}
392 onclick={() => toggleGroup('Conversations')}
393 >{collapsed.has('Conversations') ? '▶' : '▼'} Conversations ({directQuery?.data?.length ??
394 0})</button
395 >
396 {#if !collapsed.has('Conversations')}{#each directQuery?.data ?? [] as room (room.id)}{#if room.other}<button
397 class="list-entry"
398 class:unread={room.unreadCount > 0}
399 onclick={() =>
400 desktop.openIm({
401 roomId: room.id,
402 otherUserId: room.other!.id,
403 otherNickname: room.other!.nickname
404 })}
405 ><img src="/aim/running-man-16.png" alt="" width="12" height="12" />
406 <span class="truncate"
407 >{room.other.nickname}{room.unreadCount
408 ? ` (${room.unreadCount >= 100 ? '99+' : room.unreadCount})`
409 : ''}</span
410 ></button
411 >{/if}{/each}{/if}
412 {/if}
413 </div>
414 {#if me && panel === 'im'}<form
415 class="inline-panel"
416 onsubmit={(e) => {
417 e.preventDefault();
418 void startIm();
419 }}
420 >
421 <label for="im-nick">Pseudo du contact</label><input
422 id="im-nick"
423 type="text"
424 bind:value={nickname}
425 autocomplete="off"
426 /><button type="submit">Ouvrir IM</button>
427 </form>{/if}
428 {#if me && panel === 'add'}<form
429 class="inline-panel"
430 onsubmit={(e) => {
431 e.preventDefault();
432 void addBuddy();
433 }}
434 >
435 <label for="add-user">Contact</label><select id="add-user" bind:value={addId}
436 ><option value="">Choisir…</option>{#each availableUsers as user (user.id)}<option
437 value={user.id}>{user.nickname}</option
438 >{/each}</select
439 ><label for="add-group">Groupe</label><select id="add-group" bind:value={addGroup}
440 >{#each DEFAULT_BUDDY_GROUPS as group (group)}<option value={group}>{group}</option
441 >{/each}<option value="Autre…">Autre…</option></select
442 >{#if addGroup === 'Autre…'}<input
443 type="text"
444 aria-label="Nom du groupe"
445 bind:value={customGroup}
446 />{/if}<button type="submit">Ajouter</button>
447 </form>{/if}
448 {#if error}<p class="error" role="alert">{error}</p>{/if}
449 <div class="toolbar">
450 <button disabled={!me} onclick={() => openPanel('im')}
451 ><img src="/aim/running-man-48.png" alt="" />IM</button
452 >
453 <button disabled={!me} onclick={() => desktop.openNewRoom()}
454 ><img src="/aim/chat-room-48.png" alt="" />Chat</button
455 >
456 <button
457 disabled={!selectedBuddy}
458 onclick={() => {
459 if (selectedBuddy)
460 desktop.openProfile({ userId: selectedBuddy.id, nickname: selectedBuddy.nickname });
461 }}><img src="/aim/profile-48.png" alt="" />Infos</button
462 >
463 <button disabled={!me} onclick={() => desktop.openAway()}
464 ><img src="/aim/xp-note-48.png" alt="" />Absent</button
465 >
466 <button disabled={!me} onclick={() => openPanel('add')}
467 ><img src="/aim/add-buddy-48.png" alt="" />Ajouter</button
468 >
469 </div>
470 </div>
471</XpWindow>
472
473{#if context}<div
474 class="context dropdown"
475 bind:this={contextElement}
476 style:left={`${Math.max(0, context.x)}px`}
477 style:top={`${Math.max(0, context.y)}px`}
478 >
479 <button
480 onclick={() => {
481 if (context) void openIm(context.buddy.id, context.buddy.nickname);
482 context = null;
483 }}>Envoyer un message instantané</button
484 >
485 <button
486 onclick={() => {
487 if (context)
488 desktop.openProfile({ userId: context.buddy.id, nickname: context.buddy.nickname });
489 context = null;
490 }}>Infos sur {context.buddy.nickname}</button
491 >
492 <button
493 onclick={() => {
494 if (context) void removeBuddy(context.buddy.id);
495 }}>Retirer de la liste</button
496 >
497 </div>{/if}
498
499<style>
500 .buddy-content {
501 display: flex;
502 flex-direction: column;
503 flex: 1;
504 min-height: 0;
505 font:
506 11px Tahoma,
507 'Pixelated MS Sans Serif',
508 sans-serif;
509 }
510 .identity {
511 display: flex;
512 align-items: center;
513 gap: 6px;
514 min-height: 49px;
515 padding: 4px;
516 background: linear-gradient(#ffe880, #f6c700);
517 border-bottom: 1px solid #c59d00;
518 }
519 .identity p {
520 margin: 3px 0 0;
521 }
522 .icon {
523 flex: none;
524 }
525 .identity-text {
526 min-width: 0;
527 display: flex;
528 flex-direction: column;
529 gap: 3px;
530 }
531 .identity-text strong {
532 display: flex;
533 align-items: center;
534 white-space: nowrap;
535 overflow: hidden;
536 text-overflow: ellipsis;
537 }
538 .identity-text span,
539 .truncate {
540 overflow: hidden;
541 text-overflow: ellipsis;
542 white-space: nowrap;
543 }
544 .tabs {
545 display: flex;
546 padding-top: 4px;
547 }
548 .tabs button {
549 border: 1px solid #aaa;
550 border-bottom: 0;
551 border-radius: 4px 4px 0 0;
552 padding: 3px 9px;
553 min-width: 0;
554 font-size: 11px;
555 background: #dedbd0;
556 }
557 .tabs button.active {
558 background: #fff;
559 font-weight: bold;
560 }
561 .tree {
562 flex: 1;
563 min-height: 0;
564 overflow: auto;
565 background: #fff;
566 padding: 2px;
567 }
568 .group,
569 .list-entry,
570 .row-action {
571 display: flex;
572 align-items: center;
573 width: 100%;
574 min-width: 0;
575 text-align: left;
576 border: 0;
577 border-radius: 0;
578 box-shadow: none;
579 font: inherit;
580 cursor: default;
581 }
582 .group {
583 background: #ece9d8;
584 font-weight: bold;
585 padding: 3px 2px;
586 margin-top: 2px;
587 }
588 .list-entry,
589 .row-action {
590 background: transparent;
591 padding: 2px 2px 2px 12px;
592 gap: 4px;
593 }
594 .list-entry:hover,
595 .row-action:hover,
596 .row.selected .row-action {
597 background: #316ac5;
598 color: #fff;
599 }
600 .row {
601 display: flex;
602 align-items: center;
603 }
604 .row-action {
605 flex: 1;
606 }
607 .row-action.offline,
608 .row-action.away {
609 color: #777;
610 font-style: italic;
611 }
612 .row-action:hover,
613 .row.selected .row-action {
614 color: #fff;
615 }
616 .row-action img,
617 .list-entry img {
618 flex: none;
619 }
620 .remove {
621 min-width: 17px;
622 padding: 0;
623 border: 0;
624 box-shadow: none;
625 color: #900;
626 }
627 .unread {
628 font-weight: bold;
629 }
630 .inline-panel {
631 display: flex;
632 flex-direction: column;
633 gap: 3px;
634 padding: 4px;
635 background: #ece9d8;
636 }
637 .inline-panel input,
638 .inline-panel select {
639 width: 100%;
640 box-sizing: border-box;
641 }
642 .error {
643 color: #a00;
644 margin: 2px;
645 }
646 .toolbar {
647 display: flex;
648 justify-content: space-between;
649 border-top: 1px solid #aca899;
650 padding-top: 3px;
651 }
652 .toolbar button {
653 display: flex;
654 align-items: center;
655 flex-direction: column;
656 gap: 2px;
657 min-width: 0;
658 padding: 2px;
659 font: inherit;
660 }
661 .toolbar img {
662 width: 24px;
663 height: 24px;
664 }
665 .menus {
666 display: flex;
667 }
668 .menu-wrap {
669 position: relative;
670 }
671 .dropdown {
672 position: absolute;
673 z-index: 10000;
674 top: 100%;
675 left: 0;
676 min-width: 170px;
677 padding: 2px;
678 background: #fff;
679 border: 1px solid #aca899;
680 box-shadow: 2px 2px 4px #888;
681 }
682 .dropdown button {
683 display: block;
684 width: 100%;
685 min-width: 0;
686 padding: 4px 8px;
687 text-align: left;
688 border: 0;
689 box-shadow: none;
690 background: #fff;
691 color: #000;
692 white-space: nowrap;
693 font:
694 11px Tahoma,
695 sans-serif;
696 }
697 .dropdown button:hover:not(:disabled) {
698 background: #316ac5;
699 color: #fff;
700 }
701 .dropdown hr {
702 margin: 2px 4px;
703 }
704 .context {
705 position: fixed;
706 top: auto;
707 min-width: 185px;
708 }
709 @keyframes sign-on {
710 0%,
711 75% {
712 background: #fff199;
713 }
714 100% {
715 background: transparent;
716 }
717 }
718 .row.flash {
719 animation: sign-on 1.5s ease-out;
720 }
721</style>