v0.4.0 f6d8405

im-window.svelte RawHistoryFolder

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>

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