detached a826f44

fix(desktop): cancel sign-on for real, dedupe General tab, keyboard tab close, shared sound and timestamp prefs

commit
a826f442cab3f7105c170fb7084ed6abea00cea3
author
Kit · · 2026-09-29 05:59 UTC
parent
cc5d0b4
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
7 files · +130 -75
M src/lib/aim/sounds.svelte.ts +8 -10
@@ -19,19 +19,15 @@ export type AimSound = 'doorOpen' | 'doorSlam' | 'imReceive' | 'imSend' | 'welco
1919 const STORAGE_KEY = 'pdraim-sounds-enabled';
2020
2121 let ctx: AudioContext | null = null;
22-let enabled = true;
23-
24-if (browser) {
25- const stored = localStorage.getItem(STORAGE_KEY);
26- enabled = stored === null ? true : stored === 'true';
27-}
22+// Reactive so every sound toggle (taskbar, preferences) shows the same value.
23+const sound = $state({ enabled: browser ? localStorage.getItem(STORAGE_KEY) !== 'false' : true });
2824
2925 export function soundsEnabled() {
30- return enabled;
26+ return sound.enabled;
3127 }
3228
3329 export function setSoundsEnabled(value: boolean) {
34- enabled = value;
30+ sound.enabled = value;
3531 if (browser) localStorage.setItem(STORAGE_KEY, String(value));
3632 }
3733
@@ -234,7 +230,9 @@ export const SOUND_DURATIONS_MS: Record<AimSound, number> = {
234230 modem: 4500
235231 };
236232
237-// Sample cache: one in-flight/resolved promise per sound (null = no file available)
233+// Sample cache: one in-flight/resolved promise per sound (null = no file available).
234+// Not UI state, so a plain Map is intended.
235+// eslint-disable-next-line svelte/prefer-svelte-reactivity
238236 const samples = new Map<AimSound, Promise<AudioBuffer | null>>();
239237 const SAMPLE_EXTENSIONS = ['mp3', 'wav', 'ogg'];
240238
@@ -266,7 +264,7 @@ export function preloadSounds() {
266264 }
267265
268266 export function playSound(name: AimSound) {
269- if (!enabled) return;
267+ if (!sound.enabled) return;
270268 const ac = getContext();
271269 if (!ac) return;
272270 // Always wait for the sample probe so the first play never falls back to the
@@ -19,19 +19,15 @@ export type AimSound = 'doorOpen' | 'doorSlam' | 'imReceive' | 'imSend' | 'welco
19 const STORAGE_KEY = 'pdraim-sounds-enabled';19 const STORAGE_KEY = 'pdraim-sounds-enabled';
20 20
21 let ctx: AudioContext | null = null;21 let ctx: AudioContext | null = null;
22-let enabled = true;22+// Reactive so every sound toggle (taskbar, preferences) shows the same value.
23-23+const sound = $state({ enabled: browser ? localStorage.getItem(STORAGE_KEY) !== 'false' : true });
24-if (browser) {
25- const stored = localStorage.getItem(STORAGE_KEY);
26- enabled = stored === null ? true : stored === 'true';
27-}
28 24
29 export function soundsEnabled() {25 export function soundsEnabled() {
30- return enabled;26+ return sound.enabled;
31 }27 }
32 28
33 export function setSoundsEnabled(value: boolean) {29 export function setSoundsEnabled(value: boolean) {
34- enabled = value;30+ sound.enabled = value;
35 if (browser) localStorage.setItem(STORAGE_KEY, String(value));31 if (browser) localStorage.setItem(STORAGE_KEY, String(value));
36 }32 }
37 33
@@ -234,7 +230,9 @@ export const SOUND_DURATIONS_MS: Record<AimSound, number> = {
234 modem: 4500230 modem: 4500
235 };231 };
236 232
237-// Sample cache: one in-flight/resolved promise per sound (null = no file available)233+// Sample cache: one in-flight/resolved promise per sound (null = no file available).
234+// Not UI state, so a plain Map is intended.
235+// eslint-disable-next-line svelte/prefer-svelte-reactivity
238 const samples = new Map<AimSound, Promise<AudioBuffer | null>>();236 const samples = new Map<AimSound, Promise<AudioBuffer | null>>();
239 const SAMPLE_EXTENSIONS = ['mp3', 'wav', 'ogg'];237 const SAMPLE_EXTENSIONS = ['mp3', 'wav', 'ogg'];
240 238
@@ -266,7 +264,7 @@ export function preloadSounds() {
266 }264 }
267 265
268 export function playSound(name: AimSound) {266 export function playSound(name: AimSound) {
269- if (!enabled) return;267+ if (!sound.enabled) return;
270 const ac = getContext();268 const ac = getContext();
271 if (!ac) return;269 if (!ac) return;
272 // Always wait for the sample probe so the first play never falls back to the270 // Always wait for the sample probe so the first play never falls back to the
M src/lib/components/desktop/chat-rooms-window.svelte +25 -16
@@ -49,26 +49,27 @@
4949 aria-controls="room-panel-{tabKey(tab.roomId)}"
5050 title={topicOf(tab.roomId) ?? tab.roomName}
5151 onclick={() => desktop.selectRoomTab(tab.roomId)}
52+ onkeydown={(e) => {
53+ if (e.key === 'Delete' && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId);
54+ }}
5255 onauxclick={(e) => {
5356 if (e.button === 1 && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId);
5457 }}
5558 >
5659 <img src="/aim/chat-room-16.png" alt="" width="14" height="14" />
5760 <span>{tab.roomName}</span>
58- {#if desktop.roomTabs.length > 1}
59- <span
60- class="tab-close"
61- role="button"
62- tabindex="-1"
63- aria-label="Fermer {tab.roomName}"
64- onclick={(e) => {
65- e.stopPropagation();
66- desktop.closeRoomTab(tab.roomId);
67- }}
68- onkeydown={() => {}}>×</span
69- >
70- {/if}
7161 </button>
62+ {#if desktop.roomTabs.length > 1}
63+ <!-- Sibling of the tab (a button cannot contain another button) -->
64+ <button
65+ type="button"
66+ class="tab-close"
67+ class:active
68+ aria-label="Fermer {tab.roomName}"
69+ title="Fermer {tab.roomName}"
70+ onclick={() => desktop.closeRoomTab(tab.roomId)}>×</button
71+ >
72+ {/if}
7273 {/each}
7374 </menu>
7475
@@ -111,14 +112,22 @@
111112 white-space: nowrap;
112113 }
113114
114- .tab-close {
115- margin-left: 2px;
116- padding: 0 3px;
115+ .room-tabs button.tab-close {
116+ margin-left: -1px;
117+ padding: 0 4px;
117118 line-height: 12px;
118119 color: #555;
119120 cursor: default;
120121 }
121122
123+ .room-tabs button.tab-close.active {
124+ background-color: #ece9d8;
125+ position: relative;
126+ z-index: 8;
127+ margin-top: -2px;
128+ margin-bottom: 1px;
129+ }
130+
122131 .tab-close:hover {
123132 background: #c75050;
124133 color: #fff;
@@ -49,26 +49,27 @@
49 aria-controls="room-panel-{tabKey(tab.roomId)}"49 aria-controls="room-panel-{tabKey(tab.roomId)}"
50 title={topicOf(tab.roomId) ?? tab.roomName}50 title={topicOf(tab.roomId) ?? tab.roomName}
51 onclick={() => desktop.selectRoomTab(tab.roomId)}51 onclick={() => desktop.selectRoomTab(tab.roomId)}
52+ onkeydown={(e) => {
53+ if (e.key === 'Delete' && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId);
54+ }}
52 onauxclick={(e) => {55 onauxclick={(e) => {
53 if (e.button === 1 && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId);56 if (e.button === 1 && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId);
54 }}57 }}
55 >58 >
56 <img src="/aim/chat-room-16.png" alt="" width="14" height="14" />59 <img src="/aim/chat-room-16.png" alt="" width="14" height="14" />
57 <span>{tab.roomName}</span>60 <span>{tab.roomName}</span>
58- {#if desktop.roomTabs.length > 1}
59- <span
60- class="tab-close"
61- role="button"
62- tabindex="-1"
63- aria-label="Fermer {tab.roomName}"
64- onclick={(e) => {
65- e.stopPropagation();
66- desktop.closeRoomTab(tab.roomId);
67- }}
68- onkeydown={() => {}}>×</span
69- >
70- {/if}
71 </button>61 </button>
62+ {#if desktop.roomTabs.length > 1}
63+ <!-- Sibling of the tab (a button cannot contain another button) -->
64+ <button
65+ type="button"
66+ class="tab-close"
67+ class:active
68+ aria-label="Fermer {tab.roomName}"
69+ title="Fermer {tab.roomName}"
70+ onclick={() => desktop.closeRoomTab(tab.roomId)}>×</button
71+ >
72+ {/if}
72 {/each}73 {/each}
73 </menu>74 </menu>
74 75
@@ -111,14 +112,22 @@
111 white-space: nowrap;112 white-space: nowrap;
112 }113 }
113 114
114- .tab-close {115+ .room-tabs button.tab-close {
115- margin-left: 2px;116+ margin-left: -1px;
116- padding: 0 3px;117+ padding: 0 4px;
117 line-height: 12px;118 line-height: 12px;
118 color: #555;119 color: #555;
119 cursor: default;120 cursor: default;
120 }121 }
121 122
123+ .room-tabs button.tab-close.active {
124+ background-color: #ece9d8;
125+ position: relative;
126+ z-index: 8;
127+ margin-top: -2px;
128+ margin-bottom: 1px;
129+ }
130+
122 .tab-close:hover {131 .tab-close:hover {
123 background: #c75050;132 background: #c75050;
124 color: #fff;133 color: #fff;
M src/lib/components/desktop/im-window.svelte +17 -17
@@ -5,14 +5,13 @@
55 import type { Id } from '../../../convex/_generated/dataModel';
66 import type { EnrichedMessage, SafeUser } from '$lib/types/chat';
77 import { authQuery, useAimClient } from '$lib/aim/client';
8- import { playSound } from '$lib/aim/sounds';
8+ import { playSound } from '$lib/aim/sounds.svelte';
99 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
1010 import { chatState } from '$lib/states/chat.svelte';
1111 import { desktop } from '$lib/states/desktop.svelte';
1212 import { api } from '$lib/api/client';
1313 import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting';
1414 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
15- import { debounce } from '$lib/utils/chat-window-state';
1615 import { formatFrenchTime } from '$lib/utils/date-format';
1716 import XpWindow from './xp-window.svelte';
1817 import FormattedMessage from '../formatted-message.svelte';
@@ -69,14 +68,6 @@
6968 let previousRoom: string | null = null;
7069 const disabled = $derived(!currentUser || sending || cooldownSeconds > 0);
7170
72- const saveStyle = debounce((style: TextStyle) => {
73- void api.textPreferences.save({
74- defaultStyle: style,
75- allowFormatting: true,
76- maxMessageLength: MAX_MESSAGE_LENGTH
77- });
78- }, 1500);
79-
8071 // Load once per signed-in identity; do not persist the fetched default as an edit.
8172 $effect(() => {
8273 const userId = currentUser?.id;
@@ -100,12 +91,22 @@
10091 active = false;
10192 };
10293 });
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.
10396 $effect(() => {
10497 const style = JSON.stringify(textStyle);
105- if (styleReady && currentUser && style !== loadedStyle) {
98+ if (!styleReady || !currentUser || style === loadedStyle) return;
99+ const timer = setTimeout(() => {
106100 loadedStyle = style;
107- saveStyle(JSON.parse(style) as TextStyle);
108- }
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);
109110 });
110111
111112 function stopTyping() {
@@ -285,10 +286,9 @@
285286 class:me={message.senderId === currentUser?.id}
286287 class="nick"
287288 title={new Date(message.timestamp).toLocaleString('fr-FR')}
288- >{message.user.nickname} ({formatFrenchTime(new Date(message.timestamp)).replace(
289- 'h',
290- ':'
291- )}):</span
289+ >{message.user.nickname}{desktop.showTimestamps
290+ ? ` (${formatFrenchTime(new Date(message.timestamp)).replace('h', ':')})`
291+ : ''}:</span
292292 >
293293 <FormattedMessage {message} allowFormatting={true} />
294294 </div>{/if}
@@ -5,14 +5,13 @@
5 import type { Id } from '../../../convex/_generated/dataModel';5 import type { Id } from '../../../convex/_generated/dataModel';
6 import type { EnrichedMessage, SafeUser } from '$lib/types/chat';6 import type { EnrichedMessage, SafeUser } from '$lib/types/chat';
7 import { authQuery, useAimClient } from '$lib/aim/client';7 import { authQuery, useAimClient } from '$lib/aim/client';
8- import { playSound } from '$lib/aim/sounds';8+ import { playSound } from '$lib/aim/sounds.svelte';
9 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';9 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
10 import { chatState } from '$lib/states/chat.svelte';10 import { chatState } from '$lib/states/chat.svelte';
11 import { desktop } from '$lib/states/desktop.svelte';11 import { desktop } from '$lib/states/desktop.svelte';
12 import { api } from '$lib/api/client';12 import { api } from '$lib/api/client';
13 import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting';13 import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting';
14 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';14 import { MAX_MESSAGE_LENGTH } from '$lib/validation/message';
15- import { debounce } from '$lib/utils/chat-window-state';
16 import { formatFrenchTime } from '$lib/utils/date-format';15 import { formatFrenchTime } from '$lib/utils/date-format';
17 import XpWindow from './xp-window.svelte';16 import XpWindow from './xp-window.svelte';
18 import FormattedMessage from '../formatted-message.svelte';17 import FormattedMessage from '../formatted-message.svelte';
@@ -69,14 +68,6 @@
69 let previousRoom: string | null = null;68 let previousRoom: string | null = null;
70 const disabled = $derived(!currentUser || sending || cooldownSeconds > 0);69 const disabled = $derived(!currentUser || sending || cooldownSeconds > 0);
71 70
72- const saveStyle = debounce((style: TextStyle) => {
73- void api.textPreferences.save({
74- defaultStyle: style,
75- allowFormatting: true,
76- maxMessageLength: MAX_MESSAGE_LENGTH
77- });
78- }, 1500);
79-
80 // Load once per signed-in identity; do not persist the fetched default as an edit.71 // Load once per signed-in identity; do not persist the fetched default as an edit.
81 $effect(() => {72 $effect(() => {
82 const userId = currentUser?.id;73 const userId = currentUser?.id;
@@ -100,12 +91,22 @@
100 active = false;91 active = false;
101 };92 };
102 });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.
103 $effect(() => {96 $effect(() => {
104 const style = JSON.stringify(textStyle);97 const style = JSON.stringify(textStyle);
105- if (styleReady && currentUser && style !== loadedStyle) {98+ if (!styleReady || !currentUser || style === loadedStyle) return;
99+ const timer = setTimeout(() => {
106 loadedStyle = style;100 loadedStyle = style;
107- saveStyle(JSON.parse(style) as TextStyle);101+ api.textPreferences
108- }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);
109 });110 });
110 111
111 function stopTyping() {112 function stopTyping() {
@@ -285,10 +286,9 @@
285 class:me={message.senderId === currentUser?.id}286 class:me={message.senderId === currentUser?.id}
286 class="nick"287 class="nick"
287 title={new Date(message.timestamp).toLocaleString('fr-FR')}288 title={new Date(message.timestamp).toLocaleString('fr-FR')}
288- >{message.user.nickname} ({formatFrenchTime(new Date(message.timestamp)).replace(289+ >{message.user.nickname}{desktop.showTimestamps
289- 'h',290+ ? ` (${formatFrenchTime(new Date(message.timestamp)).replace('h', ':')})`
290- ':'291+ : ''}:</span
291- )}):</span
292 >292 >
293 <FormattedMessage {message} allowFormatting={true} />293 <FormattedMessage {message} allowFormatting={true} />
294 </div>{/if}294 </div>{/if}
M src/lib/components/desktop/preferences-window.svelte +11 -15
@@ -1,5 +1,4 @@
11 <script lang="ts">
2- import { onMount } from 'svelte';
32 import XpWindow from './xp-window.svelte';
43 import { desktop } from '$lib/states/desktop.svelte';
54 import {
@@ -8,7 +7,7 @@
87 playSound,
98 unlockAudio,
109 type AimSound
11- } from '$lib/aim/sounds';
10+ } from '$lib/aim/sounds.svelte';
1211
1312 const sounds: { label: string; name: AimSound }[] = [
1413 { label: "Porte qui s'ouvre", name: 'doorOpen' },
@@ -18,25 +17,21 @@
1817 { label: 'Bienvenue', name: 'welcome' },
1918 { label: 'Modem 56k', name: 'modem' }
2019 ];
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();
20+ const soundOn = $derived(soundsEnabled());
21+ function changeSound(event: Event) {
22+ const on = (event.currentTarget as HTMLInputElement).checked;
23+ setSoundsEnabled(on);
24+ if (on) unlockAudio();
3025 }
31- function changeTimestamps() {
32- localStorage.setItem('pdraim-show-timestamps', String(timestamps));
26+ function changeTimestamps(event: Event) {
27+ desktop.setShowTimestamps((event.currentTarget as HTMLInputElement).checked);
3328 }
3429 </script>
3530
3631 <XpWindow id="preferences" width={380} height={320} resizableWindow={false}>
3732 <div class="content">
3833 <label
39- ><input type="checkbox" bind:checked={soundOn} onchange={changeSound} /> Sons activés (porte, ding…)</label
34+ ><input type="checkbox" checked={soundOn} onchange={changeSound} /> Sons activés (porte, ding…)</label
4035 >
4136 <div class="sound-list">
4237 {#each sounds as sound (sound.name)}
@@ -52,7 +47,8 @@
5247 {/each}
5348 </div>
5449 <label
55- ><input type="checkbox" bind:checked={timestamps} onchange={changeTimestamps} /> Afficher les horodatages</label
50+ ><input type="checkbox" checked={desktop.showTimestamps} onchange={changeTimestamps} /> Afficher
51+ les horodatages</label
5652 >
5753 <div class="actions"><button onclick={() => desktop.close('preferences')}>Fermer</button></div>
5854 </div>
@@ -1,5 +1,4 @@
1 <script lang="ts">1 <script lang="ts">
2- import { onMount } from 'svelte';
3 import XpWindow from './xp-window.svelte';2 import XpWindow from './xp-window.svelte';
4 import { desktop } from '$lib/states/desktop.svelte';3 import { desktop } from '$lib/states/desktop.svelte';
5 import {4 import {
@@ -8,7 +7,7 @@
8 playSound,7 playSound,
9 unlockAudio,8 unlockAudio,
10 type AimSound9 type AimSound
11- } from '$lib/aim/sounds';10+ } from '$lib/aim/sounds.svelte';
12 11
13 const sounds: { label: string; name: AimSound }[] = [12 const sounds: { label: string; name: AimSound }[] = [
14 { label: "Porte qui s'ouvre", name: 'doorOpen' },13 { label: "Porte qui s'ouvre", name: 'doorOpen' },
@@ -18,25 +17,21 @@
18 { label: 'Bienvenue', name: 'welcome' },17 { label: 'Bienvenue', name: 'welcome' },
19 { label: 'Modem 56k', name: 'modem' }18 { label: 'Modem 56k', name: 'modem' }
20 ];19 ];
21- let soundOn = $state(true);20+ const soundOn = $derived(soundsEnabled());
22- let timestamps = $state(true);21+ function changeSound(event: Event) {
23- onMount(() => {22+ const on = (event.currentTarget as HTMLInputElement).checked;
24- soundOn = soundsEnabled();23+ setSoundsEnabled(on);
25- timestamps = localStorage.getItem('pdraim-show-timestamps') !== 'false';24+ if (on) unlockAudio();
26- });
27- function changeSound() {
28- setSoundsEnabled(soundOn);
29- if (soundOn) unlockAudio();
30 }25 }
31- function changeTimestamps() {26+ function changeTimestamps(event: Event) {
32- localStorage.setItem('pdraim-show-timestamps', String(timestamps));27+ desktop.setShowTimestamps((event.currentTarget as HTMLInputElement).checked);
33 }28 }
34 </script>29 </script>
35 30
36 <XpWindow id="preferences" width={380} height={320} resizableWindow={false}>31 <XpWindow id="preferences" width={380} height={320} resizableWindow={false}>
37 <div class="content">32 <div class="content">
38 <label33 <label
39- ><input type="checkbox" bind:checked={soundOn} onchange={changeSound} /> Sons activés (porte, ding…)</label34+ ><input type="checkbox" checked={soundOn} onchange={changeSound} /> Sons activés (porte, ding…)</label
40 >35 >
41 <div class="sound-list">36 <div class="sound-list">
42 {#each sounds as sound (sound.name)}37 {#each sounds as sound (sound.name)}
@@ -52,7 +47,8 @@
52 {/each}47 {/each}
53 </div>48 </div>
54 <label49 <label
55- ><input type="checkbox" bind:checked={timestamps} onchange={changeTimestamps} /> Afficher les horodatages</label50+ ><input type="checkbox" checked={desktop.showTimestamps} onchange={changeTimestamps} /> Afficher
51+ les horodatages</label
56 >52 >
57 <div class="actions"><button onclick={() => desktop.close('preferences')}>Fermer</button></div>53 <div class="actions"><button onclick={() => desktop.close('preferences')}>Fermer</button></div>
58 </div>54 </div>
M src/lib/components/desktop/sign-on-window.svelte +48 -12
@@ -11,7 +11,7 @@
1111 import { desktop } from '$lib/states/desktop.svelte';
1212 import { chatState } from '$lib/states/chat.svelte';
1313 import { createSafeUser } from '$lib/types/chat';
14- import { playSound, unlockAudio, SOUND_DURATIONS_MS } from '$lib/aim/sounds';
14+ import { playSound, unlockAudio, SOUND_DURATIONS_MS } from '$lib/aim/sounds.svelte';
1515 import {
1616 loginSchema,
1717 createRegistrationSchema,
@@ -58,18 +58,37 @@
5858 'Démarrage des services…'
5959 ];
6060
61- function wait(ms: number) {
62- return new Promise((r) => setTimeout(r, ms));
61+ // Aborted by "Annuler": stops the login request and every later step of the sequence.
62+ let signOnAbort: AbortController | null = null;
63+
64+ function wait(ms: number, signal: AbortSignal) {
65+ return new Promise<void>((resolve, reject) => {
66+ const timer = setTimeout(resolve, ms);
67+ signal.addEventListener(
68+ 'abort',
69+ () => {
70+ clearTimeout(timer);
71+ reject(signal.reason);
72+ },
73+ { once: true }
74+ );
75+ });
6376 }
6477
65- async function login(name: string, pass: string): Promise<{ ok: boolean; error?: string }> {
78+ async function login(
79+ name: string,
80+ pass: string,
81+ signal: AbortSignal
82+ ): Promise<{ ok: boolean; error?: string }> {
6683 const res = await fetch('/api/session/login', {
6784 method: 'POST',
6885 headers: { 'Content-Type': 'application/json' },
69- body: JSON.stringify({ username: name, password: pass })
86+ body: JSON.stringify({ username: name, password: pass }),
87+ signal
7088 });
7189 const data = await res.json();
7290 if (!res.ok) return { ok: false, error: data.error || 'Connexion impossible.' };
91+ signal.throwIfAborted();
7392 chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
7493 await invalidate('app:session');
7594 await invalidate('app:chat');
@@ -77,6 +96,10 @@
7796 }
7897
7998 async function runSignOn(name: string, pass: string) {
99+ signOnAbort?.abort();
100+ const controller = new AbortController();
101+ signOnAbort = controller;
102+ const { signal } = controller;
80103 unlockAudio();
81104 error = '';
82105 step = 1;
@@ -84,9 +107,9 @@
84107 playSound('modem');
85108 const modemStart = Date.now();
86109 try {
87- await wait(900);
110+ await wait(900, signal);
88111 step = 2;
89- const result = await login(name, pass);
112+ const result = await login(name, pass, signal);
90113 if (!result.ok) {
91114 error = result.error ?? 'Connexion impossible.';
92115 step = 0;
@@ -97,15 +120,28 @@
97120 step = 3;
98121 // Let the modem "handshake" finish before the welcome chime
99122 const remaining = SOUND_DURATIONS_MS.modem - (Date.now() - modemStart);
100- await wait(Math.max(400, Math.min(remaining, 2500)));
123+ await wait(Math.max(400, Math.min(remaining, 2500)), signal);
101124 playSound('welcome');
102125 signedOnAs = name;
103126 desktop.openBuddyList();
104127 desktop.focus(WINDOW_ID);
105- await wait(1200);
128+ await wait(1200, signal);
106129 desktop.close(WINDOW_ID);
130+ } catch (err) {
131+ if (!signal.aborted) throw err;
132+ // Cancelled after the credentials were sent: the server may have created a
133+ // session, so honour "Annuler" by signing out (a no-op without a session).
134+ if (step >= 2 && signOnAbort === controller) {
135+ await fetch('/api/session/logout', { method: 'POST' }).catch(() => {});
136+ chatState.setCurrentUser(null);
137+ await invalidate('app:session');
138+ }
107139 } finally {
108- desktop.signingOn = false;
140+ if (signOnAbort === controller) {
141+ signOnAbort = null;
142+ desktop.signingOn = false;
143+ if (signal.aborted) step = 0;
144+ }
109145 }
110146 }
111147
@@ -169,8 +205,8 @@
169205 }
170206
171207 function cancelSignOn() {
172- step = 0;
173- desktop.signingOn = false;
208+ // runSignOn's catch/finally resets the sequence state
209+ signOnAbort?.abort();
174210 }
175211 </script>
176212
@@ -11,7 +11,7 @@
11 import { desktop } from '$lib/states/desktop.svelte';11 import { desktop } from '$lib/states/desktop.svelte';
12 import { chatState } from '$lib/states/chat.svelte';12 import { chatState } from '$lib/states/chat.svelte';
13 import { createSafeUser } from '$lib/types/chat';13 import { createSafeUser } from '$lib/types/chat';
14- import { playSound, unlockAudio, SOUND_DURATIONS_MS } from '$lib/aim/sounds';14+ import { playSound, unlockAudio, SOUND_DURATIONS_MS } from '$lib/aim/sounds.svelte';
15 import {15 import {
16 loginSchema,16 loginSchema,
17 createRegistrationSchema,17 createRegistrationSchema,
@@ -58,18 +58,37 @@
58 'Démarrage des services…'58 'Démarrage des services…'
59 ];59 ];
60 60
61- function wait(ms: number) {61+ // Aborted by "Annuler": stops the login request and every later step of the sequence.
62- return new Promise((r) => setTimeout(r, ms));62+ let signOnAbort: AbortController | null = null;
63+
64+ function wait(ms: number, signal: AbortSignal) {
65+ return new Promise<void>((resolve, reject) => {
66+ const timer = setTimeout(resolve, ms);
67+ signal.addEventListener(
68+ 'abort',
69+ () => {
70+ clearTimeout(timer);
71+ reject(signal.reason);
72+ },
73+ { once: true }
74+ );
75+ });
63 }76 }
64 77
65- async function login(name: string, pass: string): Promise<{ ok: boolean; error?: string }> {78+ async function login(
79+ name: string,
80+ pass: string,
81+ signal: AbortSignal
82+ ): Promise<{ ok: boolean; error?: string }> {
66 const res = await fetch('/api/session/login', {83 const res = await fetch('/api/session/login', {
67 method: 'POST',84 method: 'POST',
68 headers: { 'Content-Type': 'application/json' },85 headers: { 'Content-Type': 'application/json' },
69- body: JSON.stringify({ username: name, password: pass })86+ body: JSON.stringify({ username: name, password: pass }),
87+ signal
70 });88 });
71 const data = await res.json();89 const data = await res.json();
72 if (!res.ok) return { ok: false, error: data.error || 'Connexion impossible.' };90 if (!res.ok) return { ok: false, error: data.error || 'Connexion impossible.' };
91+ signal.throwIfAborted();
73 chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);92 chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
74 await invalidate('app:session');93 await invalidate('app:session');
75 await invalidate('app:chat');94 await invalidate('app:chat');
@@ -77,6 +96,10 @@
77 }96 }
78 97
79 async function runSignOn(name: string, pass: string) {98 async function runSignOn(name: string, pass: string) {
99+ signOnAbort?.abort();
100+ const controller = new AbortController();
101+ signOnAbort = controller;
102+ const { signal } = controller;
80 unlockAudio();103 unlockAudio();
81 error = '';104 error = '';
82 step = 1;105 step = 1;
@@ -84,9 +107,9 @@
84 playSound('modem');107 playSound('modem');
85 const modemStart = Date.now();108 const modemStart = Date.now();
86 try {109 try {
87- await wait(900);110+ await wait(900, signal);
88 step = 2;111 step = 2;
89- const result = await login(name, pass);112+ const result = await login(name, pass, signal);
90 if (!result.ok) {113 if (!result.ok) {
91 error = result.error ?? 'Connexion impossible.';114 error = result.error ?? 'Connexion impossible.';
92 step = 0;115 step = 0;
@@ -97,15 +120,28 @@
97 step = 3;120 step = 3;
98 // Let the modem "handshake" finish before the welcome chime121 // Let the modem "handshake" finish before the welcome chime
99 const remaining = SOUND_DURATIONS_MS.modem - (Date.now() - modemStart);122 const remaining = SOUND_DURATIONS_MS.modem - (Date.now() - modemStart);
100- await wait(Math.max(400, Math.min(remaining, 2500)));123+ await wait(Math.max(400, Math.min(remaining, 2500)), signal);
101 playSound('welcome');124 playSound('welcome');
102 signedOnAs = name;125 signedOnAs = name;
103 desktop.openBuddyList();126 desktop.openBuddyList();
104 desktop.focus(WINDOW_ID);127 desktop.focus(WINDOW_ID);
105- await wait(1200);128+ await wait(1200, signal);
106 desktop.close(WINDOW_ID);129 desktop.close(WINDOW_ID);
130+ } catch (err) {
131+ if (!signal.aborted) throw err;
132+ // Cancelled after the credentials were sent: the server may have created a
133+ // session, so honour "Annuler" by signing out (a no-op without a session).
134+ if (step >= 2 && signOnAbort === controller) {
135+ await fetch('/api/session/logout', { method: 'POST' }).catch(() => {});
136+ chatState.setCurrentUser(null);
137+ await invalidate('app:session');
138+ }
107 } finally {139 } finally {
108- desktop.signingOn = false;140+ if (signOnAbort === controller) {
141+ signOnAbort = null;
142+ desktop.signingOn = false;
143+ if (signal.aborted) step = 0;
144+ }
109 }145 }
110 }146 }
111 147
@@ -169,8 +205,8 @@
169 }205 }
170 206
171 function cancelSignOn() {207 function cancelSignOn() {
172- step = 0;208+ // runSignOn's catch/finally resets the sequence state
173- desktop.signingOn = false;209+ signOnAbort?.abort();
174 }210 }
175 </script>211 </script>
176 212
M src/lib/components/desktop/taskbar.svelte +3 -5
@@ -2,10 +2,10 @@
22 import { onMount } from 'svelte';
33 import { desktop } from '$lib/states/desktop.svelte';
44 import { chatState } from '$lib/states/chat.svelte';
5- import { soundsEnabled, setSoundsEnabled, unlockAudio } from '$lib/aim/sounds';
5+ import { soundsEnabled, setSoundsEnabled, unlockAudio } from '$lib/aim/sounds.svelte';
66
77 let menuOpen = $state(false);
8- let soundOn = $state(true);
8+ const soundOn = $derived(soundsEnabled());
99 let clock = $state('');
1010 let menuElement = $state<HTMLElement>();
1111 let startElement = $state<HTMLButtonElement>();
@@ -16,7 +16,6 @@
1616 }
1717
1818 onMount(() => {
19- soundOn = soundsEnabled();
2019 updateClock();
2120 const timer = setInterval(updateClock, 30_000);
2221 function outside(event: PointerEvent) {
@@ -105,8 +104,7 @@
105104 aria-label={soundOn ? 'Désactiver les sons' : 'Activer les sons'}
106105 onclick={() => {
107106 unlockAudio();
108- soundOn = !soundsEnabled();
109- setSoundsEnabled(soundOn);
107+ setSoundsEnabled(!soundOn);
110108 }}
111109 ><img
112110 src={soundOn ? '/aim/xp-volume-16.png' : '/aim/xp-volume-muted-16.png'}
@@ -2,10 +2,10 @@
2 import { onMount } from 'svelte';2 import { onMount } from 'svelte';
3 import { desktop } from '$lib/states/desktop.svelte';3 import { desktop } from '$lib/states/desktop.svelte';
4 import { chatState } from '$lib/states/chat.svelte';4 import { chatState } from '$lib/states/chat.svelte';
5- import { soundsEnabled, setSoundsEnabled, unlockAudio } from '$lib/aim/sounds';5+ import { soundsEnabled, setSoundsEnabled, unlockAudio } from '$lib/aim/sounds.svelte';
6 6
7 let menuOpen = $state(false);7 let menuOpen = $state(false);
8- let soundOn = $state(true);8+ const soundOn = $derived(soundsEnabled());
9 let clock = $state('');9 let clock = $state('');
10 let menuElement = $state<HTMLElement>();10 let menuElement = $state<HTMLElement>();
11 let startElement = $state<HTMLButtonElement>();11 let startElement = $state<HTMLButtonElement>();
@@ -16,7 +16,6 @@
16 }16 }
17 17
18 onMount(() => {18 onMount(() => {
19- soundOn = soundsEnabled();
20 updateClock();19 updateClock();
21 const timer = setInterval(updateClock, 30_000);20 const timer = setInterval(updateClock, 30_000);
22 function outside(event: PointerEvent) {21 function outside(event: PointerEvent) {
@@ -105,8 +104,7 @@
105 aria-label={soundOn ? 'Désactiver les sons' : 'Activer les sons'}104 aria-label={soundOn ? 'Désactiver les sons' : 'Activer les sons'}
106 onclick={() => {105 onclick={() => {
107 unlockAudio();106 unlockAudio();
108- soundOn = !soundsEnabled();107+ setSoundsEnabled(!soundOn);
109- setSoundsEnabled(soundOn);
110 }}108 }}
111 ><img109 ><img
112 src={soundOn ? '/aim/xp-volume-16.png' : '/aim/xp-volume-muted-16.png'}110 src={soundOn ? '/aim/xp-volume-16.png' : '/aim/xp-volume-muted-16.png'}
M src/lib/states/desktop.svelte.ts +18 -0
@@ -6,6 +6,8 @@
66 */
77 import type { Id } from '../../convex/_generated/dataModel';
88
9+const SHOW_TIMESTAMPS_KEY = 'pdraim-show-timestamps';
10+
911 export type WindowKind =
1012 | 'buddy-list'
1113 | 'chat-room'
@@ -60,6 +62,16 @@ class DesktopState {
6062 /** Whether the login "sign on" sequence is running (modem animation). */
6163 signingOn = $state(false);
6264
65+ /** Preferences: show message times in IM windows. */
66+ showTimestamps = $state(
67+ typeof localStorage === 'undefined' || localStorage.getItem(SHOW_TIMESTAMPS_KEY) !== 'false'
68+ );
69+
70+ setShowTimestamps(value: boolean) {
71+ this.showTimestamps = value;
72+ localStorage.setItem(SHOW_TIMESTAMPS_KEY, String(value));
73+ }
74+
6375 focused = $derived(this.windows.find((w) => w.id === this.focusedId) ?? null);
6476 taskbarWindows = $derived([...this.windows].sort((a, b) => a.id.localeCompare(b.id)));
6577
@@ -162,6 +174,12 @@ class DesktopState {
162174 * (or selects) its tab and brings the window forward.
163175 */
164176 openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) {
177+ // General is the default room (tab id null); group names are unique case-insensitively,
178+ // so opening it by concrete id must reuse that tab instead of duplicating it.
179+ if (roomName.toLowerCase() === 'general') {
180+ roomId = null;
181+ roomName = 'General';
182+ }
165183 if (!this.roomTabs.some((t) => t.roomId === roomId)) {
166184 this.roomTabs = [...this.roomTabs, { roomId, roomName }];
167185 }
@@ -6,6 +6,8 @@
6 */6 */
7 import type { Id } from '../../convex/_generated/dataModel';7 import type { Id } from '../../convex/_generated/dataModel';
8 8
9+const SHOW_TIMESTAMPS_KEY = 'pdraim-show-timestamps';
10+
9 export type WindowKind =11 export type WindowKind =
10 | 'buddy-list'12 | 'buddy-list'
11 | 'chat-room'13 | 'chat-room'
@@ -60,6 +62,16 @@ class DesktopState {
60 /** Whether the login "sign on" sequence is running (modem animation). */62 /** Whether the login "sign on" sequence is running (modem animation). */
61 signingOn = $state(false);63 signingOn = $state(false);
62 64
65+ /** Preferences: show message times in IM windows. */
66+ showTimestamps = $state(
67+ typeof localStorage === 'undefined' || localStorage.getItem(SHOW_TIMESTAMPS_KEY) !== 'false'
68+ );
69+
70+ setShowTimestamps(value: boolean) {
71+ this.showTimestamps = value;
72+ localStorage.setItem(SHOW_TIMESTAMPS_KEY, String(value));
73+ }
74+
63 focused = $derived(this.windows.find((w) => w.id === this.focusedId) ?? null);75 focused = $derived(this.windows.find((w) => w.id === this.focusedId) ?? null);
64 taskbarWindows = $derived([...this.windows].sort((a, b) => a.id.localeCompare(b.id)));76 taskbarWindows = $derived([...this.windows].sort((a, b) => a.id.localeCompare(b.id)));
65 77
@@ -162,6 +174,12 @@ class DesktopState {
162 * (or selects) its tab and brings the window forward.174 * (or selects) its tab and brings the window forward.
163 */175 */
164 openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) {176 openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) {
177+ // General is the default room (tab id null); group names are unique case-insensitively,
178+ // so opening it by concrete id must reuse that tab instead of duplicating it.
179+ if (roomName.toLowerCase() === 'general') {
180+ roomId = null;
181+ roomName = 'General';
182+ }
165 if (!this.roomTabs.some((t) => t.roomId === roomId)) {183 if (!this.roomTabs.some((t) => t.roomId === roomId)) {
166 this.roomTabs = [...this.roomTabs, { roomId, roomName }];184 this.roomTabs = [...this.roomTabs, { roomId, roomName }];
167 }185 }

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