1<script lang="ts">
2import { chatState } from '../states/chat.svelte';
3import type { MessageType, User, ChatRoom, Message, EnrichedMessage } from '../types/chat';
4import { onMount } from 'svelte';
5import { browser } from '$app/environment';
6import { draggable } from '$lib/actions/draggable';
7import { resizable } from '$lib/actions/resizable';
8import { maximizable } from '$lib/actions/maximizable';
9
10// Initialize with default values for SSR
11let windowWidth = $state(400);
12let windowHeight = $state(500);
13let windowX = $state(0);
14let windowY = $state(0);
15let isMobile = $state(false);
16let currentMessage = $state('');
17let showUserList = $state(false);
18let isMaximized = $state(false);
19
20let { showChatRoom = $bindable() } = $props();
21
22function handleClose() {
23 showChatRoom = false;
24}
25
26// Reactive state using derived values
27let messages = $state<EnrichedMessage[]>([]);
28let onlineUsers = $state<User[]>([]);
29let currentUser = $state<User | null>(null);
30
31// Compute visible messages based on login status
32let visibleMessages = $derived((() => {
33 const isLoggedIn = Boolean(currentUser);
34 const messageCount = messages.length;
35 const result: EnrichedMessage[] = isLoggedIn
36 ? messages
37 : messages.slice(Math.max(0, messageCount - 10));
38 return result;
39})());
40
41// Single effect to handle all state updates
42$effect(() => {
43 const user = chatState.getCurrentUser();
44 if (user?.id !== currentUser?.id) {
45 currentUser = user;
46 }
47
48 // Only update messages if we have new ones
49 const stateMessages = chatState.getMessages();
50 if (stateMessages.length !== messages.length) {
51 messages = stateMessages;
52
53 // Scroll to bottom when new messages arrive
54 const chatArea = document.querySelector('.chat-area');
55 if (chatArea) {
56 setTimeout(() => {
57 chatArea.scrollTop = chatArea.scrollHeight;
58 }, 0);
59 }
60 }
61
62 // Update online users
63 const stateOnlineUsers = chatState.getOnlineUsers();
64 if (JSON.stringify(stateOnlineUsers) !== JSON.stringify(onlineUsers)) {
65 onlineUsers = stateOnlineUsers;
66 }
67});
68
69// Fetch public messages only once when needed
70let fetchedPublicMessages = false;
71let fetchedPublicRoom = false;
72
73$effect(() => {
74 if (!currentUser) {
75 // Fetch both public room data and messages in parallel but handle them in sequence
76 if (!fetchedPublicRoom && !fetchedPublicMessages) {
77 fetchedPublicRoom = true;
78 fetchedPublicMessages = true;
79
80 // Fetch both in parallel
81 Promise.all([
82 fetch('/api/rooms/default?public=true').then(r => r.json()),
83 fetch('/api/chat/messages?public=true').then(r => r.json())
84 ])
85 .then(([roomData, messagesData]) => {
86 if (roomData.success) {
87 // First update the chat state with the buddy list
88 chatState.updateUserCache(roomData.buddyList);
89 onlineUsers = roomData.buddyList;
90 }
91
92 // Then update messages after buddy list is cached
93 if (messagesData.success && messagesData.messages) {
94 chatState.updateMessages(messagesData.messages);
95 messages = chatState.enrichMessages(messagesData.messages);
96 }
97 })
98 .catch(error => {
99 console.error("Error fetching public data:", error);
100 onlineUsers = [];
101 messages = [];
102 });
103 }
104 }
105});
106
107onMount(() => {
108 if (!browser) return;
109
110 const handleResize = () => {
111 isMobile = window.innerWidth <= 768;
112 if (isMobile) {
113 windowWidth = window.innerWidth;
114 windowHeight = window.innerHeight;
115 windowX = 0;
116 windowY = 0;
117 } else {
118 windowWidth = Math.min(800, Math.max(400, window.innerWidth * 0.8));
119 windowHeight = Math.min(600, Math.max(500, window.innerHeight * 0.8));
120 windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
121 windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
122 }
123 };
124
125 // Initial resize
126 handleResize();
127
128 // Add resize listener
129 window.addEventListener('resize', handleResize);
130
131 return () => window.removeEventListener('resize', handleResize);
132});
133
134async function handleSubmit() {
135 if (!currentMessage.trim()) return;
136
137 try {
138 await chatState.sendMessage(currentMessage);
139 currentMessage = '';
140 } catch (error) {
141 console.error('Failed to send message:', error);
142 // You might want to show this error to the user in a more friendly way
143 alert('Failed to send message. Please try again.');
144 }
145}
146
147function formatTimestamp(date: Date) {
148 return date.toLocaleTimeString('en-US', {
149 hour: 'numeric',
150 minute: '2-digit',
151 hour12: true
152 });
153}
154
155function getStatusIcon(status: User['status']) {
156 switch (status) {
157 case 'online': return '🟢';
158 case 'away': return '🌙';
159 case 'busy': return '🔴';
160 default: return '⚫';
161 }
162}
163
164function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
165 windowX = event.detail.x;
166 windowY = event.detail.y;
167}
168
169interface MaximizableNode extends HTMLElement {
170 toggleMaximize: () => void;
171}
172
173function handleMaximize(event: MouseEvent) {
174 const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;
175 if (node) {
176 node.toggleMaximize();
177 }
178}
179
180function handleMaximizeEvent(event: CustomEvent<{
181 isMaximized: boolean;
182 width: number;
183 height: number;
184 x: number;
185 y: number;
186}>) {
187 isMaximized = event.detail.isMaximized;
188 windowWidth = event.detail.width;
189 windowHeight = event.detail.height;
190 windowX = event.detail.x;
191 windowY = event.detail.y;
192}
193</script>
194
195{#if showChatRoom}
196<div
197 class="chat-window window"
198 style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;"
199 use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
200 use:resizable={{
201 enabled: !isMobile && !isMaximized,
202 minWidth: 400,
203 minHeight: 500,
204 maxWidth: window.innerWidth - 40,
205 maxHeight: window.innerHeight - 40
206 }}
207 use:maximizable={{ enabled: !isMobile, padding: 4 }}
208 onmaximize={handleMaximizeEvent}
209 onresizemove={(e) => {
210 windowWidth = Math.max(400, e.detail.width);
211 windowHeight = Math.max(500, e.detail.height);
212 }}
213 ondragmove={handleDragMove}
214>
215 <div class="title-bar">
216 <div class="title-bar-text">Pdr Aim - {currentUser?.nickname}</div>
217 <div class="title-bar-controls">
218 {#if !isMobile}
219 <button aria-label="Minimize"></button>
220 <button
221 aria-label="Maximize"
222 class:maximized={isMaximized}
223 onclick={handleMaximize}
224 ></button>
225 <button onclick={handleClose} aria-label="Close"></button>
226 {:else}
227 <button
228 aria-label="Toggle Buddy List"
229 onclick={() => showUserList = !showUserList}
230 >👥</button>
231 {/if}
232 </div>
233 </div>
234
235 <div class="window-body" style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;">
236 <!-- Chat content area -->
237 <div class="chat-container" style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;">
238 <div
239 class="sunken-panel chat-area"
240 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
241 >
242 {#each visibleMessages as message (message.id)}
243 <div class="message {message.type} text">
244 {#if message.type === 'emote'}
245 <span class="timestamp">{formatTimestamp(new Date(message.timestamp))}</span>
246 <span class="emote-text">{message.user.nickname} {message.content}</span>
247 {:else}
248 <span class="timestamp">{formatTimestamp(new Date(message.timestamp))}</span>
249 <span class="nickname">{message.user.nickname}:</span>
250 <span class="content">{message.content}</span>
251 {/if}
252 </div>
253 {/each}
254 </div>
255
256 <div class="field-row input-container" style="margin: 0;">
257 <input
258 type="text"
259 bind:value={currentMessage}
260 style="flex: 1;"
261 onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
262 placeholder="Type a message..."
263 disabled={!currentUser}
264 />
265 <button onclick={handleSubmit} disabled={!currentUser}>Send</button>
266 </div>
267 </div>
268
269 <!-- Online users list -->
270 <div
271 class="sunken-panel users-list"
272 class:mobile={isMobile}
273 class:hidden={isMobile && !showUserList}
274 style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"
275 >
276 <p style="margin: 0 0 0.5rem 0;"><strong>Buddy List</strong></p>
277 {#each onlineUsers as user}
278 <div class="user">
279 <span class="status-icon">{getStatusIcon(user.status)}</span>
280 <div class="user-info">
281 <div class="nickname">{user.nickname}</div>
282 {#if user.status === 'away'}
283 <div class="status-message">Away</div>
284 {/if}
285 </div>
286 </div>
287 {/each}
288 </div>
289 </div>
290</div>
291{/if}
292
293<style>
294 :global(*) {
295 font-size: 1rem;
296 font-family: 'Microsoft Sans Serif', 'Segoe UI', Tahoma, sans-serif;
297 }
298
299 .title-bar {
300 height: 2rem;
301 position: relative;
302 cursor: move;
303 user-select: none;
304 }
305
306 .chat-window {
307 position: fixed;
308 box-sizing: border-box;
309 }
310
311 .chat-area {
312 line-height: 1.4;
313 }
314
315 .message {
316 margin-bottom: 0.5rem;
317 word-break: break-word;
318 }
319
320 .message .timestamp {
321 color: #666;
322 font-size: 0.875rem;
323 margin-right: 0.5rem;
324 }
325
326 .message .nickname {
327 font-weight: bold;
328 color: #2d31a6;
329 margin-right: 0.5rem;
330 }
331
332 .message.emote {
333 color: #666;
334 font-style: italic;
335 }
336
337 .user {
338 display: flex;
339 align-items: flex-start;
340 margin-bottom: 0.75rem;
341 gap: 0.375rem;
342 }
343
344 .status-icon {
345 font-size: 0.875rem;
346 }
347
348 .user-info {
349 flex: 1;
350 min-width: 0;
351 }
352
353 .user .nickname {
354 font-weight: bold;
355 margin-bottom: 0.125rem;
356 }
357
358 .user .status-message {
359 font-size: 0.875rem;
360 color: #666;
361 font-style: italic;
362 overflow: hidden;
363 text-overflow: ellipsis;
364 white-space: nowrap;
365 }
366
367 .sunken-panel {
368 background: white;
369 border: 0.125rem inset #dfdfdf;
370 }
371
372 input[type="text"] {
373 font-size: 1rem;
374 margin-right: 0.25rem;
375 }
376
377 button {
378 font-size: 1rem;
379 }
380
381 .chat-container {
382 position: relative;
383 z-index: 1;
384 }
385
386 .input-container {
387 position: relative;
388 z-index: 2;
389 }
390
391 .input-container input:disabled,
392 .input-container button:disabled {
393 opacity: 0.7;
394 background-color: rgba(128, 128, 128, 0.1);
395 cursor: not-allowed;
396 }
397
398 .hidden {
399 display: none !important;
400 }
401
402 :global(.resize-handle) {
403 position: absolute;
404 bottom: 0;
405 right: 0;
406 width: 15px !important;
407 height: 15px !important;
408 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Cpath d='M11 11v-2h2v2h-2zm0-4h2v2h-2V7zm-2 2V7h2v2H9zm0 2v-2h2v2H9zm-2 0v-2h2v2H7z' fill='%23000'/%3E%3C/svg%3E");
409 background-position: bottom right;
410 background-repeat: no-repeat;
411 cursor: se-resize !important;
412 }
413
414 @media (max-width: 768px) {
415 .window {
416 position: fixed !important;
417 top: 0 !important;
418 left: 0 !important;
419 right: 0 !important;
420 bottom: 0 !important;
421 width: 100% !important;
422 height: 100% !important;
423 }
424
425 .window-body {
426 flex-direction: column;
427 }
428
429 .chat-container {
430 margin-right: 0 !important;
431 margin-bottom: 0 !important;
432 height: 100%;
433 }
434
435 .users-list.mobile {
436 position: fixed;
437 bottom: 0;
438 left: 0;
439 right: 0;
440 height: auto;
441 max-height: 30vh;
442 z-index: 1000;
443 border-top: 0.125rem solid #dfdfdf;
444 background: white;
445 }
446
447 .input-container {
448 padding: 0.5rem;
449 border-top: 0.125rem solid #dfdfdf;
450 }
451
452 :global(.resize-handle) {
453 display: none;
454 }
455 }
456</style>