1/**
2 * Desktop window manager.
3 *
4 * Every window on the XP desktop (buddy list, chat rooms, IMs, dialogs) is
5 * registered here so the taskbar, focus order and z-index are consistent.
6 */
7import type { Id } from '../../convex/_generated/dataModel';
8
9const SHOW_TIMESTAMPS_KEY = 'pdraim-show-timestamps';
10
11export type WindowKind =
12 | 'buddy-list'
13 | 'chat-room'
14 | 'im'
15 | 'login'
16 | 'profile'
17 | 'away'
18 | 'new-room'
19 | 'preferences'
20 | 'about';
21
22export interface DesktopWindow {
23 id: string;
24 kind: WindowKind;
25 title: string;
26 icon: string;
27 z: number;
28 minimized: boolean;
29 /** Set when a window wants the taskbar button to blink (new IM). */
30 attention: boolean;
31 /** Kind-specific payload */
32 props: Record<string, unknown>;
33}
34
35export interface ImWindowProps {
36 roomId: Id<'chatRooms'>;
37 otherUserId: Id<'users'>;
38 otherNickname: string;
39}
40
41export interface ChatRoomTab {
42 roomId: Id<'chatRooms'> | null; // null = default room
43 roomName: string;
44}
45
46export const CHAT_WINDOW_ID = 'chat-rooms';
47
48export interface ProfileWindowProps {
49 userId: Id<'users'>;
50 nickname: string;
51}
52
53class DesktopState {
54 windows = $state<DesktopWindow[]>([]);
55 focusedId = $state<string | null>(null);
56 private nextZ = 10;
57
58 /** Open chat room tabs (all shown in the single chat-room window). */
59 roomTabs = $state<ChatRoomTab[]>([]);
60 activeRoomId = $state<Id<'chatRooms'> | null>(null);
61
62 /** Whether the login "sign on" sequence is running (modem animation). */
63 signingOn = $state(false);
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
75 focused = $derived(this.windows.find((w) => w.id === this.focusedId) ?? null);
76 taskbarWindows = $derived([...this.windows].sort((a, b) => a.id.localeCompare(b.id)));
77
78 get(id: string) {
79 return this.windows.find((w) => w.id === id) ?? null;
80 }
81
82 has(id: string) {
83 return this.windows.some((w) => w.id === id);
84 }
85
86 /**
87 * Open (or focus if already open) a window.
88 * Windows are identified by a stable id so that e.g. an IM with a given
89 * user is never opened twice.
90 */
91 open(win: Omit<DesktopWindow, 'z' | 'minimized' | 'attention'>) {
92 const existing = this.get(win.id);
93 if (existing) {
94 existing.title = win.title;
95 existing.props = win.props;
96 this.focus(win.id);
97 return existing;
98 }
99 const created: DesktopWindow = {
100 ...win,
101 z: ++this.nextZ,
102 minimized: false,
103 attention: false
104 };
105 this.windows = [...this.windows, created];
106 this.focusedId = created.id;
107 return created;
108 }
109
110 close(id: string) {
111 if (id === CHAT_WINDOW_ID) {
112 this.roomTabs = [];
113 this.activeRoomId = null;
114 }
115 this.windows = this.windows.filter((w) => w.id !== id);
116 if (this.focusedId === id) {
117 const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
118 this.focusedId = top?.id ?? null;
119 }
120 }
121
122 focus(id: string) {
123 const win = this.get(id);
124 if (!win) return;
125 win.minimized = false;
126 win.attention = false;
127 win.z = ++this.nextZ;
128 this.focusedId = id;
129 }
130
131 minimize(id: string) {
132 const win = this.get(id);
133 if (!win) return;
134 win.minimized = true;
135 if (this.focusedId === id) {
136 const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
137 this.focusedId = top?.id ?? null;
138 }
139 }
140
141 toggleFromTaskbar(id: string) {
142 const win = this.get(id);
143 if (!win) return;
144 if (win.minimized || this.focusedId !== id) {
145 this.focus(id);
146 } else {
147 this.minimize(id);
148 }
149 }
150
151 requestAttention(id: string) {
152 const win = this.get(id);
153 if (win && this.focusedId !== id) win.attention = true;
154 }
155
156 blurAll() {
157 this.focusedId = null;
158 }
159
160 // ---------- Convenience openers ----------
161
162 openBuddyList() {
163 return this.open({
164 id: 'buddy-list',
165 kind: 'buddy-list',
166 title: 'Liste de contacts',
167 icon: '/aim/running-man-16.png',
168 props: {}
169 });
170 }
171
172 /**
173 * Chat rooms live as tabs inside one "Salon" window. Opening a room adds
174 * (or selects) its tab and brings the window forward.
175 */
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 }
183 if (!this.roomTabs.some((t) => t.roomId === roomId)) {
184 this.roomTabs = [...this.roomTabs, { roomId, roomName }];
185 }
186 this.activeRoomId = roomId;
187 return this.open({
188 id: CHAT_WINDOW_ID,
189 kind: 'chat-room',
190 title: `Salon : ${roomName}`,
191 icon: '/aim/chat-room-16.png',
192 props: {}
193 });
194 }
195
196 selectRoomTab(roomId: Id<'chatRooms'> | null) {
197 const tab = this.roomTabs.find((t) => t.roomId === roomId);
198 if (!tab) return;
199 this.activeRoomId = roomId;
200 const win = this.get(CHAT_WINDOW_ID);
201 if (win) win.title = `Salon : ${tab.roomName}`;
202 }
203
204 closeRoomTab(roomId: Id<'chatRooms'> | null) {
205 const index = this.roomTabs.findIndex((t) => t.roomId === roomId);
206 if (index === -1) return;
207 this.roomTabs = this.roomTabs.filter((t) => t.roomId !== roomId);
208 if (this.roomTabs.length === 0) {
209 this.close(CHAT_WINDOW_ID);
210 return;
211 }
212 if (this.activeRoomId === roomId) {
213 const next = this.roomTabs[Math.min(index, this.roomTabs.length - 1)];
214 this.selectRoomTab(next.roomId);
215 }
216 }
217
218 openIm(props: ImWindowProps) {
219 return this.open({
220 id: `im-${props.roomId}`,
221 kind: 'im',
222 title: `${props.otherNickname} - Message instantané`,
223 icon: '/aim/running-man-16.png',
224 props: props as unknown as Record<string, unknown>
225 });
226 }
227
228 openLogin(tab: 'signin' | 'signup' = 'signin') {
229 return this.open({
230 id: 'login',
231 kind: 'login',
232 title: 'Connexion',
233 icon: '/aim/running-man-16.png',
234 props: { tab }
235 });
236 }
237
238 openProfile(props: ProfileWindowProps) {
239 return this.open({
240 id: `profile-${props.userId}`,
241 kind: 'profile',
242 title: `Infos sur ${props.nickname}`,
243 icon: '/aim/xp-info-16.png',
244 props: props as unknown as Record<string, unknown>
245 });
246 }
247
248 openAway() {
249 return this.open({
250 id: 'away',
251 kind: 'away',
252 title: 'Message d’absence',
253 icon: '/aim/xp-note-16.png',
254 props: {}
255 });
256 }
257
258 openNewRoom() {
259 return this.open({
260 id: 'new-room',
261 kind: 'new-room',
262 title: 'Nouveau salon',
263 icon: '/aim/chat-room-16.png',
264 props: {}
265 });
266 }
267
268 openPreferences() {
269 return this.open({
270 id: 'preferences',
271 kind: 'preferences',
272 title: 'Préférences',
273 icon: '/aim/xp-control-panel-16.png',
274 props: {}
275 });
276 }
277
278 openAbout() {
279 return this.open({
280 id: 'about',
281 kind: 'about',
282 title: 'À propos de PDR AIM',
283 icon: '/aim/xp-help-16.png',
284 props: {}
285 });
286 }
287}
288
289export const desktop = new DesktopState();