1<script lang="ts">
2 import { onMount, tick } from 'svelte';
3 import { useQuery } from 'convex-svelte';
4 import { api as convexApi } from '../../../convex/_generated/api';
5 import type { Id } from '../../../convex/_generated/dataModel';
6 import type { EnrichedMessage, SafeUser } from '$lib/types/chat';
7 import { authQuery, useAimClient } from '$lib/aim/client';
8 import { playSound } from '$lib/aim/sounds.svelte';
9 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
10 import { chatState } from '$lib/states/chat.svelte';
11 import { desktop } from '$lib/states/desktop.svelte';
12 import { api } from '$lib/api/client';
13 import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting';
14 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
15 import { formatFrenchTime } from '$lib/utils/date-format';
16 import XpWindow from './xp-window.svelte';
17 import FormattedMessage from '../formatted-message.svelte';
18 import TextFormattingToolbar from '../text-formatting-toolbar.svelte';
19
20 let { roomId, otherUserId, otherNickname } = $props<{
21 roomId: Id<'chatRooms'>;
22 otherUserId: Id<'users'>;
23 otherNickname: string;
24 }>();
25 const id = $derived(`im-${roomId}`);
26 const aim = useAimClient();
27 const messagesQuery = authQuery(convexApi.aim.getRoomMessages, () => ({ roomId }));
28 const typingQuery = authQuery(convexApi.aim.getRoomTyping, () => ({ roomId }));
29 const profileQuery = useQuery(convexApi.aim.getUserProfile, () => ({ userId: otherUserId }));
30 const currentUser = $derived(chatState.getCurrentUser());
31 const profile = $derived(profileQuery.data);
32 const status = $derived((profile?.status ?? 'offline') as AimStatus);
33 const messages = $derived.by<EnrichedMessage[]>(() =>
34 (messagesQuery?.data ?? []).map((msg) => ({
35 id: msg.id,
36 chatRoomId: msg.chatRoomId,
37 senderId: msg.senderId,
38 content: msg.content,
39 type: msg.type,
40 timestamp: msg.timestamp,
41 styleData: msg.styleData,
42 hasFormatting: msg.hasFormatting ?? false,
43 user: {
44 id: msg.sender?.id ?? msg.senderId,
45 nickname: msg.sender?.nickname ?? otherNickname,
46 status: (msg.sender?.status ?? 'offline') as SafeUser['status'],
47 avatarUrl: msg.sender?.avatarUrl ?? null,
48 lastSeen: null
49 }
50 }))
51 );
52 let menuOpen = $state<'file' | 'contact' | null>(null);
53 let draft = $state('');
54 let textStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE });
55 let styleReady = $state(false);
56 let loadedStyle = $state('');
57 let sending = $state(false);
58 let error = $state('');
59 let cooldownSeconds = $state(0);
60 let cooldownEnd = 0;
61 let cooldownTimer: ReturnType<typeof setInterval> | undefined;
62 let typingTimer: ReturnType<typeof setTimeout> | undefined;
63 let readTimer: ReturnType<typeof setTimeout> | undefined;
64 let lastTypingAt = 0;
65 let conversation: HTMLDivElement;
66 let firstScroll = true;
67 let previousLast: string | null = null;
68 let previousRoom: string | null = null;
69 const disabled = $derived(!currentUser || sending || cooldownSeconds > 0);
70
71 // Load once per signed-in identity; do not persist the fetched default as an edit.
72 $effect(() => {
73 const userId = currentUser?.id;
74 styleReady = false;
75 if (!userId) return;
76 let active = true;
77 void api.textPreferences
78 .get()
79 .then((prefs) => {
80 if (!active) return;
81 textStyle = { ...DEFAULT_TEXT_STYLE, ...prefs?.defaultStyle };
82 loadedStyle = JSON.stringify(textStyle);
83 styleReady = true;
84 })
85 .catch(() => {
86 if (!active) return;
87 loadedStyle = JSON.stringify(textStyle);
88 styleReady = true;
89 });
90 return () => {
91 active = false;
92 };
93 });
94 // Debounced save: the effect cleanup cancels a pending save on unmount, identity
95 // change or a newer edit, so a style is never saved under another session.
96 $effect(() => {
97 const style = JSON.stringify(textStyle);
98 if (!styleReady || !currentUser || style === loadedStyle) return;
99 const timer = setTimeout(() => {
100 loadedStyle = style;
101 api.textPreferences
102 .save({
103 defaultStyle: JSON.parse(style) as TextStyle,
104 allowFormatting: true,
105 maxMessageLength: MAX_MESSAGE_LENGTH
106 })
107 .catch((err) => console.debug('Style save failed', err));
108 }, 1500);
109 return () => clearTimeout(timer);
110 });
111
112 function stopTyping() {
113 if (typingTimer) clearTimeout(typingTimer);
114 typingTimer = undefined;
115 if (lastTypingAt) {
116 lastTypingAt = 0;
117 void aim.setTyping(roomId, false).catch(() => {});
118 }
119 }
120 function onInput() {
121 if (!currentUser) return;
122 if (!draft.trim()) {
123 stopTyping();
124 return;
125 }
126 const now = Date.now();
127 if (draft.trim() && now - lastTypingAt >= 3000) {
128 lastTypingAt = now;
129 void aim.setTyping(roomId, true).catch(() => {});
130 }
131 if (typingTimer) clearTimeout(typingTimer);
132 typingTimer = setTimeout(stopTyping, 4000);
133 }
134 function startCooldown(retryAfter: number) {
135 cooldownEnd = Date.now() + retryAfter;
136 if (cooldownTimer) clearInterval(cooldownTimer);
137 const update = () => {
138 cooldownSeconds = Math.max(0, Math.ceil((cooldownEnd - Date.now()) / 1000));
139 if (!cooldownSeconds && cooldownTimer) {
140 clearInterval(cooldownTimer);
141 cooldownTimer = undefined;
142 }
143 };
144 update();
145 if (cooldownSeconds) cooldownTimer = setInterval(update, 200);
146 }
147 async function send() {
148 if (disabled || !draft.trim() || draft.length > MAX_MESSAGE_LENGTH || !currentUser) return;
149 sending = true;
150 error = '';
151 const content = draft;
152 try {
153 const result = await api.chat.sendMessage({
154 content,
155 type: 'chat',
156 userId: currentUser.id,
157 chatRoomId: roomId,
158 styleData: JSON.stringify(textStyle)
159 });
160 if (result.success) {
161 if (draft === content) draft = '';
162 stopTyping();
163 playSound('imSend');
164 } else if (result.isRateLimited && result.retryAfter) {
165 startCooldown(result.retryAfter);
166 } else {
167 error = result.error ?? 'Impossible d’envoyer le message.';
168 }
169 } catch {
170 error = 'Impossible d’envoyer le message.';
171 } finally {
172 sending = false;
173 }
174 }
175 function onKeydown(event: KeyboardEvent) {
176 if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
177 event.preventDefault();
178 void send();
179 }
180 }
181
182 $effect(() => {
183 const room = roomId;
184 const data = messagesQuery?.data;
185 if (room !== previousRoom) {
186 previousRoom = room;
187 previousLast = null;
188 firstScroll = true;
189 }
190 if (!data) return;
191 const last = data.at(-1);
192 const signature = last ? `${last.id}:${last.timestamp}` : '';
193 if (
194 previousLast !== null &&
195 last &&
196 signature !== previousLast &&
197 last.senderId === otherUserId
198 ) {
199 playSound('imReceive');
200 if (desktop.focusedId !== id) desktop.requestAttention(id);
201 }
202 const changed = signature !== previousLast;
203 previousLast = signature;
204 if (!changed || !conversation) return;
205 const shouldScroll =
206 firstScroll ||
207 conversation.scrollTop + conversation.clientHeight >= conversation.scrollHeight - 40;
208 firstScroll = false;
209 if (shouldScroll) void tick().then(() => conversation?.scrollTo(0, conversation.scrollHeight));
210 });
211 $effect(() => {
212 const last = messagesQuery?.data?.at(-1);
213 const focused = desktop.focusedId === id && !desktop.get(id)?.minimized;
214 if (!focused || !currentUser || !messagesQuery?.data) return;
215 // Depend on the latest message and focus, not the mutation's result.
216 void last;
217 readTimer = setTimeout(() => {
218 void aim.markRoomRead(roomId).catch(() => {});
219 }, 500);
220 return () => {
221 if (readTimer) clearTimeout(readTimer);
222 };
223 });
224 onMount(() => () => {
225 stopTyping();
226 if (cooldownTimer) clearInterval(cooldownTimer);
227 if (readTimer) clearTimeout(readTimer);
228 });
229</script>
230
231<XpWindow {id} width={460} height={420} minWidth={320} minHeight={300} showMaximize>
232 {#snippet menu()}
233 <div class="menu-item">
234 <button
235 aria-expanded={menuOpen === 'file'}
236 onclick={() => (menuOpen = menuOpen === 'file' ? null : 'file')}>Fichier</button
237 >
238 {#if menuOpen === 'file'}<div class="dropdown">
239 <button
240 onclick={() => {
241 menuOpen = null;
242 desktop.close(id);
243 }}>Fermer</button
244 >
245 </div>{/if}
246 </div>
247 <div class="menu-item">
248 <button
249 aria-expanded={menuOpen === 'contact'}
250 onclick={() => (menuOpen = menuOpen === 'contact' ? null : 'contact')}>Contact</button
251 >
252 {#if menuOpen === 'contact'}<div class="dropdown">
253 <button
254 onclick={() => {
255 menuOpen = null;
256 desktop.openProfile({ userId: otherUserId, nickname: otherNickname });
257 }}>Infos sur {otherNickname}</button
258 >
259 </div>{/if}
260 </div>
261 {/snippet}
262 <div class="im-body">
263 <div class="header">
264 <img class="buddy-icon" src="/aim/running-man-48.png" alt="" width="48" height="48" />
265 <div class="identity">
266 <strong>{profile?.nickname ?? otherNickname}</strong><span
267 ><img src={`/aim/status-${status}.svg`} alt="" width="14" height="14" />
268 {STATUS_LABELS_FR[status] ?? 'Hors ligne'}</span
269 >
270 </div>
271 </div>
272 {#if status === 'away' && profile?.awayMessage}<div class="away">
273 Message d'absence : {profile.awayMessage}
274 </div>{/if}
275 <div
276 class="sunken-panel conversation"
277 bind:this={conversation}
278 role="log"
279 aria-label={`Conversation avec ${otherNickname}`}
280 >
281 {#if !messagesQuery?.data}<div class="loading">Chargement…</div>{/if}
282 {#each messages as message (message.id)}
283 {#if message.type === 'system'}<div class="system">{message.content}</div>
284 {:else}<div class="message">
285 <span
286 class:me={message.senderId === currentUser?.id}
287 class="nick"
288 title={new Date(message.timestamp).toLocaleString('fr-FR')}
289 >{message.user.nickname}{desktop.showTimestamps
290 ? ` (${formatFrenchTime(new Date(message.timestamp)).replace('h', ':')})`
291 : ''}:</span
292 >
293 <FormattedMessage {message} allowFormatting={true} />
294 </div>{/if}
295 {/each}
296 {#if typingQuery?.data?.length}<div class="typing">
297 {typingQuery.data.map((user) => user.nickname).join(', ')} est en train d'écrire<span
298 class="dots">…</span
299 >
300 </div>{/if}
301 </div>
302 <TextFormattingToolbar
303 bind:style={textStyle}
304 compact={true}
305 showFontSelector={true}
306 onSmiley={(code) => {
307 const needsSpace = draft.length > 0 && !draft.endsWith(' ');
308 draft = `${draft}${needsSpace ? ' ' : ''}${code} `;
309 document.getElementById(`im-input-${roomId}`)?.focus();
310 }}
311 />
312 <div class="compose field-row">
313 <label class="sr-only" for={`im-input-${roomId}`}>Message à {otherNickname}</label>
314 <textarea
315 id={`im-input-${roomId}`}
316 bind:value={draft}
317 oninput={onInput}
318 onkeydown={onKeydown}
319 disabled={!currentUser || cooldownSeconds > 0}
320 placeholder={currentUser ? 'Écris ton message…' : 'Connecte-toi pour discuter.'}
321 maxlength={MAX_MESSAGE_LENGTH}
322 rows="3"
323 ></textarea>
324 <button class="send" onclick={send} disabled={disabled || !draft.trim()}>Envoyer</button>
325 </div>
326 {#if cooldownSeconds > 0}<span class="notice">Patiente {cooldownSeconds}s…</span
327 >{:else if error}<span class="notice" role="alert">{error}</span>{/if}
328 {#if draft.length > MAX_MESSAGE_LENGTH * 0.8}<span class="counter"
329 >{draft.length}/{MAX_MESSAGE_LENGTH}</span
330 >{/if}
331 </div>
332</XpWindow>
333
334<style>
335 .im-body {
336 display: flex;
337 flex-direction: column;
338 min-height: 0;
339 height: 100%;
340 gap: 5px;
341 font-family: Tahoma, 'Pixelated MS Sans Serif', sans-serif;
342 }
343 .menu-item {
344 position: relative;
345 }
346 .dropdown {
347 position: absolute;
348 top: 100%;
349 left: 0;
350 z-index: 100;
351 min-width: 160px;
352 padding: 2px;
353 background: #fff;
354 border: 1px solid #aca899;
355 box-shadow: 2px 2px 4px #777;
356 }
357 .dropdown button {
358 width: 100%;
359 text-align: left;
360 white-space: nowrap;
361 }
362 .dropdown button:hover {
363 background: #316ac5;
364 color: white;
365 }
366 .header {
367 display: flex;
368 align-items: center;
369 gap: 9px;
370 flex: none;
371 padding: 2px 4px;
372 }
373 .buddy-icon {
374 flex: none;
375 }
376 .identity {
377 display: flex;
378 flex-direction: column;
379 gap: 5px;
380 min-width: 0;
381 }
382 .identity strong {
383 overflow: hidden;
384 text-overflow: ellipsis;
385 white-space: nowrap;
386 }
387 .identity span {
388 display: flex;
389 align-items: center;
390 gap: 3px;
391 }
392 .away {
393 flex: none;
394 background: #fff8cc;
395 border: 1px solid #e1c466;
396 padding: 5px;
397 overflow-wrap: anywhere;
398 }
399 .conversation {
400 flex: 1 1 auto;
401 min-height: 0;
402 background: white;
403 overflow: auto;
404 padding: 6px;
405 font:
406 12px/1.5 Tahoma,
407 'Pixelated MS Sans Serif',
408 sans-serif;
409 }
410 .message {
411 margin-bottom: 5px;
412 overflow-wrap: anywhere;
413 white-space: pre-wrap;
414 }
415 .nick {
416 font-weight: bold;
417 color: #0000c0;
418 }
419 .nick.me {
420 color: #c00000;
421 }
422 .system {
423 text-align: center;
424 color: #777;
425 font-style: italic;
426 margin: 6px 0;
427 }
428 .typing,
429 .loading {
430 color: #777;
431 font-style: italic;
432 }
433 .dots {
434 display: inline-block;
435 animation: blink 1s infinite alternate;
436 }
437 @keyframes blink {
438 from {
439 opacity: 0.25;
440 }
441 to {
442 opacity: 1;
443 }
444 }
445 .compose {
446 display: flex;
447 align-items: stretch;
448 gap: 6px;
449 flex: none;
450 }
451 textarea {
452 flex: 1;
453 min-width: 0;
454 resize: none;
455 font:
456 12px Tahoma,
457 sans-serif;
458 }
459 .send {
460 align-self: flex-end;
461 }
462 .notice {
463 color: #9b2600;
464 }
465 .counter {
466 align-self: flex-end;
467 font-size: 10px;
468 color: #777;
469 }
470 .sr-only {
471 position: absolute;
472 width: 1px;
473 height: 1px;
474 overflow: hidden;
475 clip: rect(0, 0, 0, 0);
476 white-space: nowrap;
477 }
478</style>