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 Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe 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 | |||
| 19 | 19 | const STORAGE_KEY = 'pdraim-sounds-enabled'; | |
| 20 | 20 | ||
| 21 | 21 | 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 }); | |
| 28 | 24 | ||
| 29 | 25 | export function soundsEnabled() { | |
| 30 | - | return enabled; | |
| 26 | + | return sound.enabled; | |
| 31 | 27 | } | |
| 32 | 28 | ||
| 33 | 29 | export function setSoundsEnabled(value: boolean) { | |
| 34 | - | enabled = value; | |
| 30 | + | sound.enabled = value; | |
| 35 | 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 | 230 | 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 | 236 | const samples = new Map<AimSound, Promise<AudioBuffer | null>>(); | |
| 239 | 237 | const SAMPLE_EXTENSIONS = ['mp3', 'wav', 'ogg']; | |
| 240 | 238 | ||
| @@ -266,7 +264,7 @@ export function preloadSounds() { | |||
| 266 | 264 | } | |
| 267 | 265 | ||
| 268 | 266 | export function playSound(name: AimSound) { | |
| 269 | - | if (!enabled) return; | |
| 267 | + | if (!sound.enabled) return; | |
| 270 | 268 | const ac = getContext(); | |
| 271 | 269 | if (!ac) return; | |
| 272 | 270 | // 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: 4500 | 230 | 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 the | 270 | // 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 @@ | |||
| 49 | 49 | aria-controls="room-panel-{tabKey(tab.roomId)}" | |
| 50 | 50 | title={topicOf(tab.roomId) ?? tab.roomName} | |
| 51 | 51 | onclick={() => desktop.selectRoomTab(tab.roomId)} | |
| 52 | + | onkeydown={(e) => { | |
| 53 | + | if (e.key === 'Delete' && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId); | |
| 54 | + | }} | |
| 52 | 55 | onauxclick={(e) => { | |
| 53 | 56 | if (e.button === 1 && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId); | |
| 54 | 57 | }} | |
| 55 | 58 | > | |
| 56 | 59 | <img src="/aim/chat-room-16.png" alt="" width="14" height="14" /> | |
| 57 | 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 | 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 | 73 | {/each} | |
| 73 | 74 | </menu> | |
| 74 | 75 | ||
| @@ -111,14 +112,22 @@ | |||
| 111 | 112 | white-space: nowrap; | |
| 112 | 113 | } | |
| 113 | 114 | ||
| 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; | |
| 117 | 118 | line-height: 12px; | |
| 118 | 119 | color: #555; | |
| 119 | 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 | 131 | .tab-close:hover { | |
| 123 | 132 | background: #c75050; | |
| 124 | 133 | 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 @@ | |||
| 5 | 5 | import type { Id } from '../../../convex/_generated/dataModel'; | |
| 6 | 6 | import type { EnrichedMessage, SafeUser } from '$lib/types/chat'; | |
| 7 | 7 | import { authQuery, useAimClient } from '$lib/aim/client'; | |
| 8 | - | import { playSound } from '$lib/aim/sounds'; | |
| 8 | + | import { playSound } from '$lib/aim/sounds.svelte'; | |
| 9 | 9 | import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim'; | |
| 10 | 10 | import { chatState } from '$lib/states/chat.svelte'; | |
| 11 | 11 | import { desktop } from '$lib/states/desktop.svelte'; | |
| 12 | 12 | import { api } from '$lib/api/client'; | |
| 13 | 13 | import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting'; | |
| 14 | 14 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | |
| 15 | - | import { debounce } from '$lib/utils/chat-window-state'; | |
| 16 | 15 | import { formatFrenchTime } from '$lib/utils/date-format'; | |
| 17 | 16 | import XpWindow from './xp-window.svelte'; | |
| 18 | 17 | import FormattedMessage from '../formatted-message.svelte'; | |
| @@ -69,14 +68,6 @@ | |||
| 69 | 68 | let previousRoom: string | null = null; | |
| 70 | 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 | 71 | // Load once per signed-in identity; do not persist the fetched default as an edit. | |
| 81 | 72 | $effect(() => { | |
| 82 | 73 | const userId = currentUser?.id; | |
| @@ -100,12 +91,22 @@ | |||
| 100 | 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 | 96 | $effect(() => { | |
| 104 | 97 | const style = JSON.stringify(textStyle); | |
| 105 | - | if (styleReady && currentUser && style !== loadedStyle) { | |
| 98 | + | if (!styleReady || !currentUser || style === loadedStyle) return; | |
| 99 | + | const timer = setTimeout(() => { | |
| 106 | 100 | 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); | |
| 109 | 110 | }); | |
| 110 | 111 | ||
| 111 | 112 | function stopTyping() { | |
| @@ -285,10 +286,9 @@ | |||
| 285 | 286 | class:me={message.senderId === currentUser?.id} | |
| 286 | 287 | class="nick" | |
| 287 | 288 | 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 | |
| 292 | 292 | > | |
| 293 | 293 | <FormattedMessage {message} allowFormatting={true} /> | |
| 294 | 294 | </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 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | - | import { onMount } from 'svelte'; | |
| 3 | 2 | import XpWindow from './xp-window.svelte'; | |
| 4 | 3 | import { desktop } from '$lib/states/desktop.svelte'; | |
| 5 | 4 | import { | |
| @@ -8,7 +7,7 @@ | |||
| 8 | 7 | playSound, | |
| 9 | 8 | unlockAudio, | |
| 10 | 9 | type AimSound | |
| 11 | - | } from '$lib/aim/sounds'; | |
| 10 | + | } from '$lib/aim/sounds.svelte'; | |
| 12 | 11 | ||
| 13 | 12 | const sounds: { label: string; name: AimSound }[] = [ | |
| 14 | 13 | { label: "Porte qui s'ouvre", name: 'doorOpen' }, | |
| @@ -18,25 +17,21 @@ | |||
| 18 | 17 | { label: 'Bienvenue', name: 'welcome' }, | |
| 19 | 18 | { label: 'Modem 56k', name: 'modem' } | |
| 20 | 19 | ]; | |
| 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(); | |
| 30 | 25 | } | |
| 31 | - | function changeTimestamps() { | |
| 32 | - | localStorage.setItem('pdraim-show-timestamps', String(timestamps)); | |
| 26 | + | function changeTimestamps(event: Event) { | |
| 27 | + | desktop.setShowTimestamps((event.currentTarget as HTMLInputElement).checked); | |
| 33 | 28 | } | |
| 34 | 29 | </script> | |
| 35 | 30 | ||
| 36 | 31 | <XpWindow id="preferences" width={380} height={320} resizableWindow={false}> | |
| 37 | 32 | <div class="content"> | |
| 38 | 33 | <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 | |
| 40 | 35 | > | |
| 41 | 36 | <div class="sound-list"> | |
| 42 | 37 | {#each sounds as sound (sound.name)} | |
| @@ -52,7 +47,8 @@ | |||
| 52 | 47 | {/each} | |
| 53 | 48 | </div> | |
| 54 | 49 | <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 | |
| 56 | 52 | > | |
| 57 | 53 | <div class="actions"><button onclick={() => desktop.close('preferences')}>Fermer</button></div> | |
| 58 | 54 | </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 AimSound | 9 | 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 | <label | 33 | <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 |
| 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 | <label | 49 | <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 | |||
| 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 @@ | |||
| 11 | 11 | import { desktop } from '$lib/states/desktop.svelte'; | |
| 12 | 12 | import { chatState } from '$lib/states/chat.svelte'; | |
| 13 | 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 | 15 | import { | |
| 16 | 16 | loginSchema, | |
| 17 | 17 | createRegistrationSchema, | |
| @@ -58,18 +58,37 @@ | |||
| 58 | 58 | 'Démarrage des services…' | |
| 59 | 59 | ]; | |
| 60 | 60 | ||
| 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 | + | }); | |
| 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 | 83 | const res = await fetch('/api/session/login', { | |
| 67 | 84 | method: 'POST', | |
| 68 | 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 | 89 | const data = await res.json(); | |
| 72 | 90 | if (!res.ok) return { ok: false, error: data.error || 'Connexion impossible.' }; | |
| 91 | + | signal.throwIfAborted(); | |
| 73 | 92 | chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | |
| 74 | 93 | await invalidate('app:session'); | |
| 75 | 94 | await invalidate('app:chat'); | |
| @@ -77,6 +96,10 @@ | |||
| 77 | 96 | } | |
| 78 | 97 | ||
| 79 | 98 | async function runSignOn(name: string, pass: string) { | |
| 99 | + | signOnAbort?.abort(); | |
| 100 | + | const controller = new AbortController(); | |
| 101 | + | signOnAbort = controller; | |
| 102 | + | const { signal } = controller; | |
| 80 | 103 | unlockAudio(); | |
| 81 | 104 | error = ''; | |
| 82 | 105 | step = 1; | |
| @@ -84,9 +107,9 @@ | |||
| 84 | 107 | playSound('modem'); | |
| 85 | 108 | const modemStart = Date.now(); | |
| 86 | 109 | try { | |
| 87 | - | await wait(900); | |
| 110 | + | await wait(900, signal); | |
| 88 | 111 | step = 2; | |
| 89 | - | const result = await login(name, pass); | |
| 112 | + | const result = await login(name, pass, signal); | |
| 90 | 113 | if (!result.ok) { | |
| 91 | 114 | error = result.error ?? 'Connexion impossible.'; | |
| 92 | 115 | step = 0; | |
| @@ -97,15 +120,28 @@ | |||
| 97 | 120 | step = 3; | |
| 98 | 121 | // Let the modem "handshake" finish before the welcome chime | |
| 99 | 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 | 124 | playSound('welcome'); | |
| 102 | 125 | signedOnAs = name; | |
| 103 | 126 | desktop.openBuddyList(); | |
| 104 | 127 | desktop.focus(WINDOW_ID); | |
| 105 | - | await wait(1200); | |
| 128 | + | await wait(1200, signal); | |
| 106 | 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 | 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 | 207 | function cancelSignOn() { | |
| 172 | - | step = 0; | |
| 173 | - | desktop.signingOn = false; | |
| 208 | + | // runSignOn's catch/finally resets the sequence state | |
| 209 | + | signOnAbort?.abort(); | |
| 174 | 210 | } | |
| 175 | 211 | </script> | |
| 176 | 212 | ||
| @@ -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 chime | 121 | // 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 @@ | |||
| 2 | 2 | import { onMount } from 'svelte'; | |
| 3 | 3 | import { desktop } from '$lib/states/desktop.svelte'; | |
| 4 | 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 | 7 | let menuOpen = $state(false); | |
| 8 | - | let soundOn = $state(true); | |
| 8 | + | const soundOn = $derived(soundsEnabled()); | |
| 9 | 9 | let clock = $state(''); | |
| 10 | 10 | let menuElement = $state<HTMLElement>(); | |
| 11 | 11 | let startElement = $state<HTMLButtonElement>(); | |
| @@ -16,7 +16,6 @@ | |||
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | 18 | onMount(() => { | |
| 19 | - | soundOn = soundsEnabled(); | |
| 20 | 19 | updateClock(); | |
| 21 | 20 | const timer = setInterval(updateClock, 30_000); | |
| 22 | 21 | function outside(event: PointerEvent) { | |
| @@ -105,8 +104,7 @@ | |||
| 105 | 104 | aria-label={soundOn ? 'Désactiver les sons' : 'Activer les sons'} | |
| 106 | 105 | onclick={() => { | |
| 107 | 106 | unlockAudio(); | |
| 108 | - | soundOn = !soundsEnabled(); | |
| 109 | - | setSoundsEnabled(soundOn); | |
| 107 | + | setSoundsEnabled(!soundOn); | |
| 110 | 108 | }} | |
| 111 | 109 | ><img | |
| 112 | 110 | 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 | ><img | 109 | ><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 @@ | |||
| 6 | 6 | */ | |
| 7 | 7 | import type { Id } from '../../convex/_generated/dataModel'; | |
| 8 | 8 | ||
| 9 | + | const SHOW_TIMESTAMPS_KEY = 'pdraim-show-timestamps'; | |
| 10 | + | ||
| 9 | 11 | export type WindowKind = | |
| 10 | 12 | | 'buddy-list' | |
| 11 | 13 | | 'chat-room' | |
| @@ -60,6 +62,16 @@ class DesktopState { | |||
| 60 | 62 | /** Whether the login "sign on" sequence is running (modem animation). */ | |
| 61 | 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 | 75 | focused = $derived(this.windows.find((w) => w.id === this.focusedId) ?? null); | |
| 64 | 76 | taskbarWindows = $derived([...this.windows].sort((a, b) => a.id.localeCompare(b.id))); | |
| 65 | 77 | ||
| @@ -162,6 +174,12 @@ class DesktopState { | |||
| 162 | 174 | * (or selects) its tab and brings the window forward. | |
| 163 | 175 | */ | |
| 164 | 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 | 183 | if (!this.roomTabs.some((t) => t.roomId === roomId)) { | |
| 166 | 184 | this.roomTabs = [...this.roomTabs, { roomId, roomName }]; | |
| 167 | 185 | } | |
| @@ -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 | } | ||