detached 6750577

feat(chat-room): open rooms as XP tabs in a single Salon window

- desktop state tracks roomTabs/activeRoomId; openChatRoom adds or selects a tab
- new chat-rooms-window hosts the menu bar, XP tablist and one mounted ChatRoom
  per tab so drafts and scroll survive tab switches
- chat-room is now window-less content
commit
6750577fb5f456dbe026c0d316070b11b624d73a
author
Kit · · 2026-09-27 13:48 UTC
parent
cb6b0af
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
4 files · +205 -36
M src/lib/components/chat-room.svelte +13 -27
@@ -12,7 +12,6 @@
1212 import LoadingButton from './ui/button-loading.svelte';
1313 import Tooltip from './ui/tooltip.svelte';
1414 import LoadingDots from './ui/loading-dots.svelte';
15- import XpWindow from './desktop/xp-window.svelte';
1615 import { desktop } from '$lib/states/desktop.svelte';
1716 import { useAimClient } from '$lib/aim/client';
1817 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
@@ -37,16 +36,16 @@
3736 let {
3837 roomId: requestedRoomId = null,
3938 roomName = 'General',
40- initialTextStyle = DEFAULT_TEXT_STYLE
39+ initialTextStyle = DEFAULT_TEXT_STYLE,
40+ showUserList = false
4141 } = $props<{
4242 roomId?: Id<'chatRooms'> | null;
4343 roomName?: string;
4444 initialTextStyle?: TextStyle;
45+ /** Mobile only: whether the people list is expanded */
46+ showUserList?: boolean;
4547 }>();
4648
47- // svelte-ignore state_referenced_locally
48- const windowId = requestedRoomId ? `room-${requestedRoomId}` : 'room-default';
49-
5049 const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL);
5150 const aim = useAimClient();
5251
@@ -176,7 +175,6 @@
176175 // ============ WINDOW STATE ============
177176
178177 let isMobile = $state(false);
179- let showUserList = $state(false);
180178
181179 // ============ USER LIST INTERACTIONS (AIM-style) ============
182180
@@ -1052,26 +1050,7 @@
10521050 });
10531051 </script>
10541052
1055-<XpWindow
1056- id={windowId}
1057- width={800}
1058- height={600}
1059- minWidth={420}
1060- minHeight={420}
1061- showMaximize
1062- bodyClass="chat-room-body"
1063->
1064- {#snippet menu()}
1065- <button type="button" onclick={() => desktop.openBuddyList()}>Contacts</button>
1066- <button type="button" onclick={() => desktop.openNewRoom()}>Nouveau salon</button>
1067- <button type="button" onclick={() => (showUserList = !showUserList)}>
1068- {showUserList || !isMobile ? 'Participants' : 'Participants'}
1069- </button>
1070- {#if !currentUser}
1071- <button type="button" onclick={() => desktop.openLogin('signin')}>Se connecter</button>
1072- {/if}
1073- {/snippet}
1074-
1053+<div class="chat-room">
10751054 <div class="room-header">
10761055 <img src="/aim/chat-room-16.png" alt="" width="16" height="16" />
10771056 <span class="room-name">{roomName}</span>
@@ -1448,9 +1427,16 @@
14481427 </div>
14491428 {/if}
14501429 </div>
1451-</XpWindow>
1430+</div>
14521431
14531432 <style>
1433+ .chat-room {
1434+ flex: 1 1 auto;
1435+ min-height: 0;
1436+ display: flex;
1437+ flex-direction: column;
1438+ }
1439+
14541440 .chat-area,
14551441 .users-list {
14561442 font-size: 1rem;
@@ -12,7 +12,6 @@
12 import LoadingButton from './ui/button-loading.svelte';12 import LoadingButton from './ui/button-loading.svelte';
13 import Tooltip from './ui/tooltip.svelte';13 import Tooltip from './ui/tooltip.svelte';
14 import LoadingDots from './ui/loading-dots.svelte';14 import LoadingDots from './ui/loading-dots.svelte';
15- import XpWindow from './desktop/xp-window.svelte';
16 import { desktop } from '$lib/states/desktop.svelte';15 import { desktop } from '$lib/states/desktop.svelte';
17 import { useAimClient } from '$lib/aim/client';16 import { useAimClient } from '$lib/aim/client';
18 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';17 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
@@ -37,16 +36,16 @@
37 let {36 let {
38 roomId: requestedRoomId = null,37 roomId: requestedRoomId = null,
39 roomName = 'General',38 roomName = 'General',
40- initialTextStyle = DEFAULT_TEXT_STYLE39+ initialTextStyle = DEFAULT_TEXT_STYLE,
40+ showUserList = false
41 } = $props<{41 } = $props<{
42 roomId?: Id<'chatRooms'> | null;42 roomId?: Id<'chatRooms'> | null;
43 roomName?: string;43 roomName?: string;
44 initialTextStyle?: TextStyle;44 initialTextStyle?: TextStyle;
45+ /** Mobile only: whether the people list is expanded */
46+ showUserList?: boolean;
45 }>();47 }>();
46 48
47- // svelte-ignore state_referenced_locally
48- const windowId = requestedRoomId ? `room-${requestedRoomId}` : 'room-default';
49-
50 const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL);49 const isClient = browser && Boolean(env.PUBLIC_CONVEX_URL);
51 const aim = useAimClient();50 const aim = useAimClient();
52 51
@@ -176,7 +175,6 @@
176 // ============ WINDOW STATE ============175 // ============ WINDOW STATE ============
177 176
178 let isMobile = $state(false);177 let isMobile = $state(false);
179- let showUserList = $state(false);
180 178
181 // ============ USER LIST INTERACTIONS (AIM-style) ============179 // ============ USER LIST INTERACTIONS (AIM-style) ============
182 180
@@ -1052,26 +1050,7 @@
1052 });1050 });
1053 </script>1051 </script>
1054 1052
1055-<XpWindow1053+<div class="chat-room">
1056- id={windowId}
1057- width={800}
1058- height={600}
1059- minWidth={420}
1060- minHeight={420}
1061- showMaximize
1062- bodyClass="chat-room-body"
1063->
1064- {#snippet menu()}
1065- <button type="button" onclick={() => desktop.openBuddyList()}>Contacts</button>
1066- <button type="button" onclick={() => desktop.openNewRoom()}>Nouveau salon</button>
1067- <button type="button" onclick={() => (showUserList = !showUserList)}>
1068- {showUserList || !isMobile ? 'Participants' : 'Participants'}
1069- </button>
1070- {#if !currentUser}
1071- <button type="button" onclick={() => desktop.openLogin('signin')}>Se connecter</button>
1072- {/if}
1073- {/snippet}
1074-
1075 <div class="room-header">1054 <div class="room-header">
1076 <img src="/aim/chat-room-16.png" alt="" width="16" height="16" />1055 <img src="/aim/chat-room-16.png" alt="" width="16" height="16" />
1077 <span class="room-name">{roomName}</span>1056 <span class="room-name">{roomName}</span>
@@ -1448,9 +1427,16 @@
1448 </div>1427 </div>
1449 {/if}1428 {/if}
1450 </div>1429 </div>
1451-</XpWindow>1430+</div>
1452 1431
1453 <style>1432 <style>
1433+ .chat-room {
1434+ flex: 1 1 auto;
1435+ min-height: 0;
1436+ display: flex;
1437+ flex-direction: column;
1438+ }
1439+
1454 .chat-area,1440 .chat-area,
1455 .users-list {1441 .users-list {
1456 font-size: 1rem;1442 font-size: 1rem;
A src/lib/components/desktop/chat-rooms-window.svelte +145 -0
@@ -0,0 +1,145 @@
1+<script lang="ts">
2+ import ChatRoom from '../chat-room.svelte';
3+ import XpWindow from './xp-window.svelte';
4+ import { desktop, CHAT_WINDOW_ID } from '$lib/states/desktop.svelte';
5+ import { chatState } from '$lib/states/chat.svelte';
6+ import type { TextStyle } from '$lib/types/text-formatting';
7+
8+ let { initialTextStyle }: { initialTextStyle?: TextStyle } = $props();
9+
10+ const currentUser = $derived(chatState.getCurrentUser());
11+ let showUserList = $state(false);
12+
13+ const tabKey = (roomId: string | null) => roomId ?? 'default';
14+</script>
15+
16+<XpWindow
17+ id={CHAT_WINDOW_ID}
18+ width={800}
19+ height={600}
20+ minWidth={420}
21+ minHeight={420}
22+ showMaximize
23+ bodyClass="chat-rooms-body"
24+>
25+ {#snippet menu()}
26+ <button type="button" onclick={() => desktop.openBuddyList()}>Contacts</button>
27+ <button type="button" onclick={() => desktop.openNewRoom()}>Nouveau salon</button>
28+ <button type="button" onclick={() => (showUserList = !showUserList)}>Participants</button>
29+ {#if !currentUser}
30+ <button type="button" onclick={() => desktop.openLogin('signin')}>Se connecter</button>
31+ {/if}
32+ {/snippet}
33+
34+ <menu role="tablist" class="room-tabs">
35+ {#each desktop.roomTabs as tab (tabKey(tab.roomId))}
36+ {@const active = tab.roomId === desktop.activeRoomId}
37+ <button
38+ type="button"
39+ role="tab"
40+ aria-selected={active}
41+ aria-controls="room-panel-{tabKey(tab.roomId)}"
42+ onclick={() => desktop.selectRoomTab(tab.roomId)}
43+ onauxclick={(e) => {
44+ if (e.button === 1 && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId);
45+ }}
46+ >
47+ <img src="/aim/chat-room-16.png" alt="" width="14" height="14" />
48+ <span>{tab.roomName}</span>
49+ {#if desktop.roomTabs.length > 1}
50+ <span
51+ class="tab-close"
52+ role="button"
53+ tabindex="-1"
54+ aria-label="Fermer {tab.roomName}"
55+ onclick={(e) => {
56+ e.stopPropagation();
57+ desktop.closeRoomTab(tab.roomId);
58+ }}
59+ onkeydown={() => {}}>×</span
60+ >
61+ {/if}
62+ </button>
63+ {/each}
64+ </menu>
65+
66+ <div class="tab-panels">
67+ {#each desktop.roomTabs as tab (tabKey(tab.roomId))}
68+ <!-- Every open room stays mounted so switching tabs keeps scroll and draft -->
69+ <div
70+ id="room-panel-{tabKey(tab.roomId)}"
71+ role="tabpanel"
72+ class="tab-panel"
73+ hidden={tab.roomId !== desktop.activeRoomId}
74+ >
75+ <ChatRoom roomId={tab.roomId} roomName={tab.roomName} {initialTextStyle} {showUserList} />
76+ </div>
77+ {/each}
78+ </div>
79+</XpWindow>
80+
81+<style>
82+ .room-tabs {
83+ flex: 0 0 auto;
84+ margin-top: 2px;
85+ }
86+
87+ .room-tabs button {
88+ display: flex;
89+ align-items: center;
90+ gap: 4px;
91+ padding: 2px 8px 3px;
92+ font:
93+ 11px Tahoma,
94+ 'Pixelated MS Sans Serif',
95+ sans-serif;
96+ max-width: 180px;
97+ }
98+
99+ .room-tabs button span:first-of-type {
100+ overflow: hidden;
101+ text-overflow: ellipsis;
102+ white-space: nowrap;
103+ }
104+
105+ .tab-close {
106+ margin-left: 2px;
107+ padding: 0 3px;
108+ line-height: 12px;
109+ color: #555;
110+ cursor: default;
111+ }
112+
113+ .tab-close:hover {
114+ background: #c75050;
115+ color: #fff;
116+ }
117+
118+ .tab-panels {
119+ flex: 1 1 auto;
120+ min-height: 0;
121+ display: flex;
122+ flex-direction: column;
123+ background: #fcfcfe;
124+ border: 1px solid #919b9c;
125+ padding: 4px;
126+ position: relative;
127+ z-index: 2;
128+ }
129+
130+ /* xp.css frames every [role=tabpanel]; the outer .tab-panels is the frame here */
131+ .tab-panel {
132+ padding: 0;
133+ margin: 0;
134+ border: 0;
135+ background: transparent;
136+ flex: 1 1 auto;
137+ min-height: 0;
138+ display: flex;
139+ flex-direction: column;
140+ }
141+
142+ .tab-panel[hidden] {
143+ display: none;
144+ }
145+</style>
@@ -0,0 +1,145 @@
1+<script lang="ts">
2+ import ChatRoom from '../chat-room.svelte';
3+ import XpWindow from './xp-window.svelte';
4+ import { desktop, CHAT_WINDOW_ID } from '$lib/states/desktop.svelte';
5+ import { chatState } from '$lib/states/chat.svelte';
6+ import type { TextStyle } from '$lib/types/text-formatting';
7+
8+ let { initialTextStyle }: { initialTextStyle?: TextStyle } = $props();
9+
10+ const currentUser = $derived(chatState.getCurrentUser());
11+ let showUserList = $state(false);
12+
13+ const tabKey = (roomId: string | null) => roomId ?? 'default';
14+</script>
15+
16+<XpWindow
17+ id={CHAT_WINDOW_ID}
18+ width={800}
19+ height={600}
20+ minWidth={420}
21+ minHeight={420}
22+ showMaximize
23+ bodyClass="chat-rooms-body"
24+>
25+ {#snippet menu()}
26+ <button type="button" onclick={() => desktop.openBuddyList()}>Contacts</button>
27+ <button type="button" onclick={() => desktop.openNewRoom()}>Nouveau salon</button>
28+ <button type="button" onclick={() => (showUserList = !showUserList)}>Participants</button>
29+ {#if !currentUser}
30+ <button type="button" onclick={() => desktop.openLogin('signin')}>Se connecter</button>
31+ {/if}
32+ {/snippet}
33+
34+ <menu role="tablist" class="room-tabs">
35+ {#each desktop.roomTabs as tab (tabKey(tab.roomId))}
36+ {@const active = tab.roomId === desktop.activeRoomId}
37+ <button
38+ type="button"
39+ role="tab"
40+ aria-selected={active}
41+ aria-controls="room-panel-{tabKey(tab.roomId)}"
42+ onclick={() => desktop.selectRoomTab(tab.roomId)}
43+ onauxclick={(e) => {
44+ if (e.button === 1 && desktop.roomTabs.length > 1) desktop.closeRoomTab(tab.roomId);
45+ }}
46+ >
47+ <img src="/aim/chat-room-16.png" alt="" width="14" height="14" />
48+ <span>{tab.roomName}</span>
49+ {#if desktop.roomTabs.length > 1}
50+ <span
51+ class="tab-close"
52+ role="button"
53+ tabindex="-1"
54+ aria-label="Fermer {tab.roomName}"
55+ onclick={(e) => {
56+ e.stopPropagation();
57+ desktop.closeRoomTab(tab.roomId);
58+ }}
59+ onkeydown={() => {}}>×</span
60+ >
61+ {/if}
62+ </button>
63+ {/each}
64+ </menu>
65+
66+ <div class="tab-panels">
67+ {#each desktop.roomTabs as tab (tabKey(tab.roomId))}
68+ <!-- Every open room stays mounted so switching tabs keeps scroll and draft -->
69+ <div
70+ id="room-panel-{tabKey(tab.roomId)}"
71+ role="tabpanel"
72+ class="tab-panel"
73+ hidden={tab.roomId !== desktop.activeRoomId}
74+ >
75+ <ChatRoom roomId={tab.roomId} roomName={tab.roomName} {initialTextStyle} {showUserList} />
76+ </div>
77+ {/each}
78+ </div>
79+</XpWindow>
80+
81+<style>
82+ .room-tabs {
83+ flex: 0 0 auto;
84+ margin-top: 2px;
85+ }
86+
87+ .room-tabs button {
88+ display: flex;
89+ align-items: center;
90+ gap: 4px;
91+ padding: 2px 8px 3px;
92+ font:
93+ 11px Tahoma,
94+ 'Pixelated MS Sans Serif',
95+ sans-serif;
96+ max-width: 180px;
97+ }
98+
99+ .room-tabs button span:first-of-type {
100+ overflow: hidden;
101+ text-overflow: ellipsis;
102+ white-space: nowrap;
103+ }
104+
105+ .tab-close {
106+ margin-left: 2px;
107+ padding: 0 3px;
108+ line-height: 12px;
109+ color: #555;
110+ cursor: default;
111+ }
112+
113+ .tab-close:hover {
114+ background: #c75050;
115+ color: #fff;
116+ }
117+
118+ .tab-panels {
119+ flex: 1 1 auto;
120+ min-height: 0;
121+ display: flex;
122+ flex-direction: column;
123+ background: #fcfcfe;
124+ border: 1px solid #919b9c;
125+ padding: 4px;
126+ position: relative;
127+ z-index: 2;
128+ }
129+
130+ /* xp.css frames every [role=tabpanel]; the outer .tab-panels is the frame here */
131+ .tab-panel {
132+ padding: 0;
133+ margin: 0;
134+ border: 0;
135+ background: transparent;
136+ flex: 1 1 auto;
137+ min-height: 0;
138+ display: flex;
139+ flex-direction: column;
140+ }
141+
142+ .tab-panel[hidden] {
143+ display: none;
144+ }
145+</style>
M src/lib/states/desktop.svelte.ts +43 -4
@@ -36,11 +36,13 @@ export interface ImWindowProps {
3636 otherNickname: string;
3737 }
3838
39-export interface ChatRoomWindowProps {
39+export interface ChatRoomTab {
4040 roomId: Id<'chatRooms'> | null; // null = default room
4141 roomName: string;
4242 }
4343
44+export const CHAT_WINDOW_ID = 'chat-rooms';
45+
4446 export interface ProfileWindowProps {
4547 userId: Id<'users'>;
4648 nickname: string;
@@ -51,6 +53,10 @@ class DesktopState {
5153 focusedId = $state<string | null>(null);
5254 private nextZ = 10;
5355
56+ /** Open chat room tabs (all shown in the single chat-room window). */
57+ roomTabs = $state<ChatRoomTab[]>([]);
58+ activeRoomId = $state<Id<'chatRooms'> | null>(null);
59+
5460 /** Whether the login "sign on" sequence is running (modem animation). */
5561 signingOn = $state(false);
5662
@@ -90,6 +96,10 @@ class DesktopState {
9096 }
9197
9298 close(id: string) {
99+ if (id === CHAT_WINDOW_ID) {
100+ this.roomTabs = [];
101+ this.activeRoomId = null;
102+ }
93103 this.windows = this.windows.filter((w) => w.id !== id);
94104 if (this.focusedId === id) {
95105 const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
@@ -147,17 +157,46 @@ class DesktopState {
147157 });
148158 }
149159
160+ /**
161+ * Chat rooms live as tabs inside one "Salon" window. Opening a room adds
162+ * (or selects) its tab and brings the window forward.
163+ */
150164 openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) {
151- const props: ChatRoomWindowProps = { roomId, roomName };
165+ if (!this.roomTabs.some((t) => t.roomId === roomId)) {
166+ this.roomTabs = [...this.roomTabs, { roomId, roomName }];
167+ }
168+ this.activeRoomId = roomId;
152169 return this.open({
153- id: roomId ? `room-${roomId}` : 'room-default',
170+ id: CHAT_WINDOW_ID,
154171 kind: 'chat-room',
155172 title: `Salon : ${roomName}`,
156173 icon: '/aim/chat-room-16.png',
157- props: props as unknown as Record<string, unknown>
174+ props: {}
158175 });
159176 }
160177
178+ selectRoomTab(roomId: Id<'chatRooms'> | null) {
179+ const tab = this.roomTabs.find((t) => t.roomId === roomId);
180+ if (!tab) return;
181+ this.activeRoomId = roomId;
182+ const win = this.get(CHAT_WINDOW_ID);
183+ if (win) win.title = `Salon : ${tab.roomName}`;
184+ }
185+
186+ closeRoomTab(roomId: Id<'chatRooms'> | null) {
187+ const index = this.roomTabs.findIndex((t) => t.roomId === roomId);
188+ if (index === -1) return;
189+ this.roomTabs = this.roomTabs.filter((t) => t.roomId !== roomId);
190+ if (this.roomTabs.length === 0) {
191+ this.close(CHAT_WINDOW_ID);
192+ return;
193+ }
194+ if (this.activeRoomId === roomId) {
195+ const next = this.roomTabs[Math.min(index, this.roomTabs.length - 1)];
196+ this.selectRoomTab(next.roomId);
197+ }
198+ }
199+
161200 openIm(props: ImWindowProps) {
162201 return this.open({
163202 id: `im-${props.roomId}`,
@@ -36,11 +36,13 @@ export interface ImWindowProps {
36 otherNickname: string;36 otherNickname: string;
37 }37 }
38 38
39-export interface ChatRoomWindowProps {39+export interface ChatRoomTab {
40 roomId: Id<'chatRooms'> | null; // null = default room40 roomId: Id<'chatRooms'> | null; // null = default room
41 roomName: string;41 roomName: string;
42 }42 }
43 43
44+export const CHAT_WINDOW_ID = 'chat-rooms';
45+
44 export interface ProfileWindowProps {46 export interface ProfileWindowProps {
45 userId: Id<'users'>;47 userId: Id<'users'>;
46 nickname: string;48 nickname: string;
@@ -51,6 +53,10 @@ class DesktopState {
51 focusedId = $state<string | null>(null);53 focusedId = $state<string | null>(null);
52 private nextZ = 10;54 private nextZ = 10;
53 55
56+ /** Open chat room tabs (all shown in the single chat-room window). */
57+ roomTabs = $state<ChatRoomTab[]>([]);
58+ activeRoomId = $state<Id<'chatRooms'> | null>(null);
59+
54 /** Whether the login "sign on" sequence is running (modem animation). */60 /** Whether the login "sign on" sequence is running (modem animation). */
55 signingOn = $state(false);61 signingOn = $state(false);
56 62
@@ -90,6 +96,10 @@ class DesktopState {
90 }96 }
91 97
92 close(id: string) {98 close(id: string) {
99+ if (id === CHAT_WINDOW_ID) {
100+ this.roomTabs = [];
101+ this.activeRoomId = null;
102+ }
93 this.windows = this.windows.filter((w) => w.id !== id);103 this.windows = this.windows.filter((w) => w.id !== id);
94 if (this.focusedId === id) {104 if (this.focusedId === id) {
95 const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];105 const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
@@ -147,17 +157,46 @@ class DesktopState {
147 });157 });
148 }158 }
149 159
160+ /**
161+ * Chat rooms live as tabs inside one "Salon" window. Opening a room adds
162+ * (or selects) its tab and brings the window forward.
163+ */
150 openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) {164 openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) {
151- const props: ChatRoomWindowProps = { roomId, roomName };165+ if (!this.roomTabs.some((t) => t.roomId === roomId)) {
166+ this.roomTabs = [...this.roomTabs, { roomId, roomName }];
167+ }
168+ this.activeRoomId = roomId;
152 return this.open({169 return this.open({
153- id: roomId ? `room-${roomId}` : 'room-default',170+ id: CHAT_WINDOW_ID,
154 kind: 'chat-room',171 kind: 'chat-room',
155 title: `Salon : ${roomName}`,172 title: `Salon : ${roomName}`,
156 icon: '/aim/chat-room-16.png',173 icon: '/aim/chat-room-16.png',
157- props: props as unknown as Record<string, unknown>174+ props: {}
158 });175 });
159 }176 }
160 177
178+ selectRoomTab(roomId: Id<'chatRooms'> | null) {
179+ const tab = this.roomTabs.find((t) => t.roomId === roomId);
180+ if (!tab) return;
181+ this.activeRoomId = roomId;
182+ const win = this.get(CHAT_WINDOW_ID);
183+ if (win) win.title = `Salon : ${tab.roomName}`;
184+ }
185+
186+ closeRoomTab(roomId: Id<'chatRooms'> | null) {
187+ const index = this.roomTabs.findIndex((t) => t.roomId === roomId);
188+ if (index === -1) return;
189+ this.roomTabs = this.roomTabs.filter((t) => t.roomId !== roomId);
190+ if (this.roomTabs.length === 0) {
191+ this.close(CHAT_WINDOW_ID);
192+ return;
193+ }
194+ if (this.activeRoomId === roomId) {
195+ const next = this.roomTabs[Math.min(index, this.roomTabs.length - 1)];
196+ this.selectRoomTab(next.roomId);
197+ }
198+ }
199+
161 openIm(props: ImWindowProps) {200 openIm(props: ImWindowProps) {
162 return this.open({201 return this.open({
163 id: `im-${props.roomId}`,202 id: `im-${props.roomId}`,
M src/routes/+page.svelte +4 -5
@@ -1,7 +1,7 @@
11 <script lang="ts">
22 import { onMount } from 'svelte';
33 import { browser } from '$app/environment';
4- import ChatRoom from '$lib/components/chat-room.svelte';
4+ import ChatRoomsWindow from '$lib/components/desktop/chat-rooms-window.svelte';
55 import DesktopIcons from '$lib/components/desktop-icons.svelte';
66 import Taskbar from '$lib/components/desktop/taskbar.svelte';
77 import BuddyList from '$lib/components/desktop/buddy-list.svelte';
@@ -14,7 +14,7 @@
1414 import PreferencesWindow from '$lib/components/desktop/preferences-window.svelte';
1515 import {
1616 desktop,
17- type ChatRoomWindowProps,
17+ CHAT_WINDOW_ID,
1818 type ImWindowProps,
1919 type ProfileWindowProps
2020 } from '$lib/states/desktop.svelte';
@@ -34,7 +34,7 @@
3434 } else {
3535 desktop.openLogin('signin');
3636 }
37- desktop.focus('room-default');
37+ desktop.focus(CHAT_WINDOW_ID);
3838 });
3939
4040 // When the session appears (login) make sure the buddy list is around;
@@ -68,8 +68,7 @@
6868
6969 {#each desktop.windows as win (win.id)}
7070 {#if win.kind === 'chat-room'}
71- {@const p = win.props as unknown as ChatRoomWindowProps}
72- <ChatRoom roomId={p.roomId} roomName={p.roomName} initialTextStyle={pageData.textStyle} />
71+ <ChatRoomsWindow initialTextStyle={pageData.textStyle} />
7372 {:else if win.kind === 'buddy-list'}
7473 <BuddyList />
7574 {:else if win.kind === 'im'}
@@ -1,7 +1,7 @@
1 <script lang="ts">1 <script lang="ts">
2 import { onMount } from 'svelte';2 import { onMount } from 'svelte';
3 import { browser } from '$app/environment';3 import { browser } from '$app/environment';
4- import ChatRoom from '$lib/components/chat-room.svelte';4+ import ChatRoomsWindow from '$lib/components/desktop/chat-rooms-window.svelte';
5 import DesktopIcons from '$lib/components/desktop-icons.svelte';5 import DesktopIcons from '$lib/components/desktop-icons.svelte';
6 import Taskbar from '$lib/components/desktop/taskbar.svelte';6 import Taskbar from '$lib/components/desktop/taskbar.svelte';
7 import BuddyList from '$lib/components/desktop/buddy-list.svelte';7 import BuddyList from '$lib/components/desktop/buddy-list.svelte';
@@ -14,7 +14,7 @@
14 import PreferencesWindow from '$lib/components/desktop/preferences-window.svelte';14 import PreferencesWindow from '$lib/components/desktop/preferences-window.svelte';
15 import {15 import {
16 desktop,16 desktop,
17- type ChatRoomWindowProps,17+ CHAT_WINDOW_ID,
18 type ImWindowProps,18 type ImWindowProps,
19 type ProfileWindowProps19 type ProfileWindowProps
20 } from '$lib/states/desktop.svelte';20 } from '$lib/states/desktop.svelte';
@@ -34,7 +34,7 @@
34 } else {34 } else {
35 desktop.openLogin('signin');35 desktop.openLogin('signin');
36 }36 }
37- desktop.focus('room-default');37+ desktop.focus(CHAT_WINDOW_ID);
38 });38 });
39 39
40 // When the session appears (login) make sure the buddy list is around;40 // When the session appears (login) make sure the buddy list is around;
@@ -68,8 +68,7 @@
68 68
69 {#each desktop.windows as win (win.id)}69 {#each desktop.windows as win (win.id)}
70 {#if win.kind === 'chat-room'}70 {#if win.kind === 'chat-room'}
71- {@const p = win.props as unknown as ChatRoomWindowProps}71+ <ChatRoomsWindow initialTextStyle={pageData.textStyle} />
72- <ChatRoom roomId={p.roomId} roomName={p.roomName} initialTextStyle={pageData.textStyle} />
73 {:else if win.kind === 'buddy-list'}72 {:else if win.kind === 'buddy-list'}
74 <BuddyList />73 <BuddyList />
75 {:else if win.kind === 'im'}74 {:else if win.kind === 'im'}

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