1<script lang="ts">
2import { chatState } from '../states/chat.svelte';
3import type { MessageType, User, ChatRoom, Message, EnrichedMessage, SafeUser } 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';
9import { minimizable } from '$lib/actions/minimizable';
10import LoadingButton from './ui/button-loading.svelte';
11import Tooltip from './ui/tooltip.svelte';
12import LoadingDots from './ui/loading-dots.svelte';
13import AimLogin from './aim-login.svelte';
14import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';
15
16// Initialize with default values for SSR
17let windowWidth = $state(400);
18let windowHeight = $state(500);
19let windowX = $state(0);
20let windowY = $state(0);
21let isMobile = $state(false);
22let currentMessage = $state('');
23let showUserList = $state(false);
24let isMaximized = $state(false);
25let isInitialLoading = $state(true);
26let showAuth = $state(false);
27let isMinimized = $state(false);
28
29// Rate limiting state
30let cooldownEndTime = $state<number | null>(null);
31let cooldownProgress = $state(0);
32let cooldownInterval: ReturnType<typeof setInterval> | null = null;
33let rateLimitWarning = $state<string | null>(null);
34
35// At the top along with other variable declarations, add:
36let roomPollInterval: ReturnType<typeof setInterval> | null = null;
37
38function updateCooldownProgress() {
39 if (!cooldownEndTime) return;
40
41 const now = Date.now();
42 if (now >= cooldownEndTime) {
43 cooldownEndTime = null;
44 cooldownProgress = 0;
45 rateLimitWarning = null;
46 if (cooldownInterval) {
47 clearInterval(cooldownInterval);
48 cooldownInterval = null;
49 }
50 return;
51 }
52
53 cooldownProgress = ((cooldownEndTime - now) / 1000);
54}
55
56function startCooldownTimer(retryAfter: number) {
57 cooldownEndTime = Date.now() + retryAfter;
58 if (cooldownInterval) clearInterval(cooldownInterval);
59 cooldownInterval = setInterval(updateCooldownProgress, 100);
60 updateCooldownProgress();
61}
62
63let { showChatRoom = $bindable() } = $props();
64
65function handleClose() {
66 showChatRoom = false;
67}
68
69// Reactive state using derived values
70let messages = $state<EnrichedMessage[]>([]);
71let onlineUsers = $state<SafeUser[]>([]);
72let currentUser = $state<SafeUser | null>(null);
73let isLoadingMore = $state(false);
74let hasMoreMessages = $state(true);
75let oldestMessageTimestamp = $state<number | null>(null);
76let currentRoomId = $state('');
77
78// Compute visible messages based on login status
79let visibleMessages = $derived((() => {
80 const isLoggedIn = Boolean(currentUser);
81 const messageCount = messages.length;
82 const result: EnrichedMessage[] = isLoggedIn
83 ? messages
84 : messages.slice(Math.max(0, messageCount - 50));
85 return result;
86})());
87
88// Add a derived state to check if we're showing the registration prompt
89let showRegistrationPrompt = $derived(!currentUser && messages.length > 50);
90
91// Add SSE error state
92let sseError = $state<string | null>(null);
93let sseRetryAfter = $state<number | null>(null);
94
95// Update the effect to check for SSE errors
96$effect(() => {
97 const user = chatState.getCurrentUser();
98 if (user?.id !== currentUser?.id) {
99 currentUser = user;
100 hasMoreMessages = true; // Reset when user changes
101 }
102
103 // Check for SSE errors
104 const { error, retryAfter } = chatState.getSSEError();
105 sseError = error;
106 sseRetryAfter = retryAfter;
107
108 // Update messages if there's any difference
109 const stateMessages = chatState.getMessages();
110 if (JSON.stringify(stateMessages) !== JSON.stringify(messages)) {
111 messages = stateMessages;
112
113 // Update oldest message timestamp
114 if (messages.length > 0) {
115 oldestMessageTimestamp = Math.min(...messages.map(m => m.timestamp));
116 }
117
118 // Scroll to bottom when new messages arrive
119 const chatArea = document.querySelector('.chat-area');
120 if (chatArea) {
121 setTimeout(() => {
122 chatArea.scrollTop = chatArea.scrollHeight;
123 }, 0);
124 }
125 }
126
127 // Update online users
128 const stateOnlineUsers = chatState.getOnlineUsers();
129 if (JSON.stringify(stateOnlineUsers) !== JSON.stringify(onlineUsers)) {
130 onlineUsers = stateOnlineUsers;
131 }
132});
133
134// Fetch public messages only once when needed
135let fetchedPublicMessages = false;
136let fetchedPublicRoom = false;
137
138$effect(() => {
139 if (!currentUser) {
140 // Fetch both public room data and messages in parallel but handle them in sequence
141 if (!fetchedPublicRoom && !fetchedPublicMessages) {
142 fetchedPublicRoom = true;
143 fetchedPublicMessages = true;
144
145 // Get the default chat room id
146 const defaultRoomId = chatState.getDefaultChatRoomId();
147
148 // Fetch both in parallel
149 Promise.all([
150 fetch(`/api/rooms/${defaultRoomId}?public=true`).then(r => r.json()),
151 fetch('/api/chat/messages?public=true').then(r => r.json())
152 ])
153 .then(([roomData, messagesData]) => {
154 if (roomData.success) {
155 // Update both the chat state and local state with the buddy list
156 chatState.updateOnlineUsers(roomData.buddyList);
157 onlineUsers = roomData.buddyList;
158 console.debug('Updated public buddy list:', roomData.buddyList);
159 }
160
161 // Then update messages after buddy list is cached
162 if (messagesData.success && messagesData.messages) {
163 chatState.updateMessages(messagesData.messages);
164 messages = chatState.enrichMessages(messagesData.messages);
165 console.debug('Updated public messages:', messages);
166 }
167 })
168 .catch(error => {
169 console.error("Error fetching public data:", error);
170 onlineUsers = [];
171 messages = [];
172 });
173 }
174 }
175});
176
177onMount(() => {
178 if (!browser) return;
179
180 const handleResize = () => {
181 isMobile = window.innerWidth <= 768;
182 if (isMobile) {
183 windowWidth = window.innerWidth;
184 windowHeight = window.innerHeight;
185 windowX = 0;
186 windowY = 0;
187 } else {
188 windowWidth = Math.min(800, Math.max(400, window.innerWidth * 0.8));
189 windowHeight = Math.min(600, Math.max(500, window.innerHeight * 0.8));
190 windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
191 windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
192 }
193 };
194
195 // Initial resize
196 handleResize();
197
198 // Add resize listener
199 window.addEventListener('resize', handleResize);
200
201 // ---- New: Polling for public buddy list updates ----
202 if (!currentUser) {
203 const fetchPublicRoom = async () => {
204 const defaultRoomId = chatState.getDefaultChatRoomId();
205 try {
206 const response = await fetch(`/api/rooms/${defaultRoomId}?public=true`);
207 if (response.ok) {
208 const roomData = await response.json();
209 if (roomData.success && roomData.buddyList) {
210 // Update both chat state and local onlineUsers list
211 chatState.updateOnlineUsers(roomData.buddyList);
212 onlineUsers = roomData.buddyList;
213 console.debug('Public polling updated buddy list:', roomData.buddyList);
214 }
215 }
216 } catch (error) {
217 console.error('Error fetching public buddy list:', error);
218 }
219 };
220
221 // Initial fetch then poll every 30 seconds
222 fetchPublicRoom();
223 roomPollInterval = setInterval(fetchPublicRoom, 30000);
224 }
225 // -------------------------------------------------------
226
227 return () => {
228 window.removeEventListener('resize', handleResize);
229 if (cooldownInterval) clearInterval(cooldownInterval);
230 if (roomPollInterval) clearInterval(roomPollInterval);
231 };
232});
233
234// New reactive state to track when a message is being sent
235let isSendingMessage = $state(false);
236
237async function handleSubmit() {
238 if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;
239
240 isSendingMessage = true;
241 try {
242 const response = await chatState.sendMessage(currentMessage);
243 if (!response.success && response.isRateLimited && response.retryAfter) {
244 startCooldownTimer(response.retryAfter);
245 rateLimitWarning = 'Whoa there! You\'re sending messages too quickly. Take a breather...';
246 } else if (!response.success) {
247 rateLimitWarning = response.error || 'Failed to send message. Please try again.';
248 setTimeout(() => rateLimitWarning = null, 3000);
249 } else {
250 currentMessage = '';
251 rateLimitWarning = null;
252 }
253 } catch (error) {
254 console.error('Failed to send message:', error);
255 rateLimitWarning = 'Failed to send message. Please try again.';
256 setTimeout(() => rateLimitWarning = null, 3000);
257 } finally {
258 isSendingMessage = false;
259 }
260}
261
262function getStatusIcon(status: User['status']) {
263 switch (status) {
264 case 'online': return '🟢';
265 case 'away': return '🌙';
266 case 'busy': return '🔴';
267 default: return '⚫';
268 }
269}
270
271function formatLastSeen(lastSeen: number | null | undefined) {
272 if (!lastSeen) return 'Never';
273
274 const now = Date.now();
275 const diff = now - lastSeen;
276
277 // Less than a minute
278 if (diff < 60000) {
279 return 'Just now';
280 }
281
282 // Less than an hour
283 if (diff < 3600000) {
284 const minutes = Math.floor(diff / 60000);
285 return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
286 }
287
288 // Less than a day
289 if (diff < 86400000) {
290 const hours = Math.floor(diff / 3600000);
291 return `${hours} hour${hours > 1 ? 's' : ''} ago`;
292 }
293
294 // Less than a week
295 if (diff < 604800000) {
296 const days = Math.floor(diff / 86400000);
297 return `${days} day${days > 1 ? 's' : ''} ago`;
298 }
299
300 // More than a week
301 return new Date(lastSeen).toLocaleDateString();
302}
303
304function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
305 windowX = event.detail.x;
306 windowY = event.detail.y;
307}
308
309interface MaximizableNode extends HTMLElement {
310 toggleMaximize: () => void;
311}
312
313function handleMaximize(event: MouseEvent) {
314 const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;
315 if (node) {
316 // If minimized, restore first
317 if (isMinimized) {
318 const minimizableNode = node as any;
319 if (minimizableNode?.toggleMinimize) {
320 minimizableNode.toggleMinimize();
321 }
322 // Wait for minimize animation to complete
323 setTimeout(() => {
324 node.toggleMaximize();
325 }, 300);
326 } else {
327 node.toggleMaximize();
328 }
329 }
330}
331
332function handleMaximizeEvent(event: CustomEvent<{
333 isMaximized: boolean;
334 width: number;
335 height: number;
336 x: number;
337 y: number;
338}>) {
339 isMaximized = event.detail.isMaximized;
340 windowWidth = event.detail.width;
341 windowHeight = event.detail.height;
342 windowX = event.detail.x;
343 windowY = event.detail.y;
344}
345
346function handleMinimize(event: CustomEvent<{ isMinimized: boolean }>) {
347 isMinimized = event.detail.isMinimized;
348}
349
350async function handleScroll(event: Event) {
351 const chatArea = event.target as HTMLElement;
352 const { scrollTop } = chatArea;
353
354 // Check if we've scrolled near the top (within 100px) and not already loading
355 if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) {
356 isLoadingMore = true;
357
358 // Store the scroll height and position before loading
359 const scrollHeight = chatArea.scrollHeight;
360 const currentScrollTop = chatArea.scrollTop;
361
362 console.debug('Loading more messages...', {
363 oldestMessageTimestamp,
364 scrollHeight,
365 currentScrollTop
366 });
367
368 try {
369 const response = await fetch(`/api/chat/messages?${new URLSearchParams({
370 before: oldestMessageTimestamp?.toString() || '',
371 roomId: currentRoomId,
372 ...(currentUser ? {} : { public: 'true' })
373 })}`);
374
375 if (response.ok) {
376 const data = await response.json();
377 if (data.success) {
378 if (data.messages.length === 0) {
379 hasMoreMessages = false;
380 } else {
381 // Update messages through chat state
382 chatState.prependMessages(data.messages, data.hasMore);
383
384 // Use requestAnimationFrame to ensure DOM has updated
385 requestAnimationFrame(() => {
386 // Calculate how much the content height has increased
387 const newScrollHeight = chatArea.scrollHeight;
388 const heightDifference = newScrollHeight - scrollHeight;
389
390 // Adjust scroll position to maintain relative position
391 chatArea.scrollTop = currentScrollTop + heightDifference;
392
393 console.debug('Scroll position adjusted:', {
394 heightDifference,
395 newScrollTop: chatArea.scrollTop,
396 newScrollHeight
397 });
398 });
399
400 // Update oldest timestamp
401 const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp));
402 oldestMessageTimestamp = newOldest;
403
404 // Update hasMoreMessages from response
405 hasMoreMessages = data.hasMore;
406 }
407 }
408 }
409 } catch (error) {
410 console.error('Error loading more messages:', error);
411 hasMoreMessages = false;
412 } finally {
413 isLoadingMore = false;
414 }
415 }
416}
417
418// Add effect to track initial loading state
419$effect(() => {
420 const stateMessages = chatState.getMessages();
421 if (stateMessages.length > 0 && isInitialLoading) {
422 isInitialLoading = false;
423 }
424});
425
426// Add derived state for total users count
427let totalUsers = $derived(onlineUsers.length);
428
429function openSignup() {
430 showAuth = true;
431}
432
433function handleLoginSuccess() {
434 showAuth = false;
435}
436</script>
437
438{#if showChatRoom}
439<div
440 class="chat-window window"
441 class:minimized={isMinimized}
442 style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;"
443 use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
444 use:resizable={{
445 enabled: !isMobile && !isMaximized && !isMinimized,
446 minWidth: 400,
447 minHeight: 500,
448 maxWidth: window.innerWidth - 40,
449 maxHeight: window.innerHeight - 40
450 }}
451 use:maximizable={{ enabled: !isMobile && !isMinimized, padding: 4 }}
452 use:minimizable={{ enabled: !isMobile }}
453 onmaximize={handleMaximizeEvent}
454 onminimize={handleMinimize}
455 onresizemove={(e) => {
456 windowWidth = Math.max(400, e.detail.width);
457 windowHeight = Math.max(500, e.detail.height);
458 }}
459 ondragmove={handleDragMove}
460>
461 <div class="title-bar">
462 <div class="title-bar-text" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
463 Pdr Aim {#if currentUser} - {currentUser.nickname}{:else} - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
464 {#if sseError && !isMinimized}
465 <span class="connection-error">⚠️ Erreur de connexion</span>
466 {/if}
467 </div>
468 <div class="title-bar-controls">
469 {#if !isMobile}
470 <button aria-label="Minimize" onclick={(e) => {
471 e.stopPropagation();
472 const node = (e.currentTarget as HTMLElement).closest('.window') as any;
473 if (node?.toggleMinimize) node.toggleMinimize();
474 }}></button>
475 <button
476 aria-label="Maximize"
477 class:maximized={isMaximized}
478 onclick={handleMaximize}
479 ></button>
480 <button onclick={handleClose} aria-label="Close"></button>
481 {:else}
482 <button
483 aria-label="Afficher/Masquer la liste des contacts"
484 onclick={() => showUserList = !showUserList}
485 class="contacts-btn"
486 >👥</button>
487 <button
488 aria-label="Minimize"
489 onclick={(e) => {
490 e.stopPropagation();
491 const node = (e.currentTarget as HTMLElement).closest('.window') as any;
492 if (node?.toggleMinimize) node.toggleMinimize();
493 }}
494 ></button>
495 <button onclick={handleClose} aria-label="Close"></button>
496 {/if}
497 </div>
498 </div>
499
500 {#if !isMinimized}
501 <div class="window-body" style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;">
502 {#if sseError}
503 <div class="error-banner">
504 {sseError}
505 {#if sseRetryAfter}
506 <br>
507 <small>Nouvelle tentative dans {Math.ceil(sseRetryAfter)}s...</small>
508 {/if}
509 </div>
510 {/if}
511
512 <div class="chat-container" style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;">
513 {#if rateLimitWarning}
514 <div class="rate-limit-warning" class:with-progress={cooldownEndTime}>
515 <span>{rateLimitWarning}</span>
516 {#if cooldownEndTime}
517 <small>Vous pourrez envoyer un autre message dans {cooldownProgress.toFixed(1)}s</small>
518 {/if}
519 </div>
520 {/if}
521
522 <div
523 class="sunken-panel chat-area"
524 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
525 onscroll={(e) => handleScroll(e)}
526 >
527 {#if isInitialLoading}
528 <div class="initial-loading">
529 <LoadingDots text="Chargement des messages" />
530 </div>
531 {:else if isLoadingMore}
532 <div class="loading-messages">
533 <LoadingDots text="Chargement" />
534 </div>
535 {/if}
536 {#if showRegistrationPrompt}
537 <div class="registration-prompt">
538 <p>👋 <button class="link-button" onclick={openSignup}>Inscris-toi</button>pour lire le reste du chat !</p>
539 </div>
540 {/if}
541 {#each visibleMessages as message (message.id)}
542 <div class="message {message.type} text">
543 {#if message.type === 'emote'}
544 <span class="emote-text">
545 <Tooltip data={{
546 text: formatFrenchDateTime(new Date(message.timestamp)),
547 direction: "left",
548 closeDelay: 1000,
549 touchBehavior: "remove",
550 interactive: false
551 }}>
552 <span class="nickname pointer-events-none">{message.user.nickname}</span>
553 </Tooltip>
554 {message.content}
555 </span>
556 {:else}
557 <Tooltip data={{
558 text: formatFrenchDateTime(new Date(message.timestamp)),
559 direction: "left",
560 closeDelay: 1000,
561 touchBehavior: "remove",
562 interactive: false
563 }}>
564 <span class="nickname pointer-events-none">{message.user.nickname}:</span>
565 </Tooltip>
566 <span class="content">{message.content}</span>
567 {/if}
568 </div>
569 {/each}
570 </div>
571
572 <div class="field-row input-container" style="margin: 0;">
573 <input
574 type="text"
575 bind:value={currentMessage}
576 style="flex: 1;"
577 onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
578 placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}
579 disabled={!currentUser || Boolean(cooldownEndTime)}
580 />
581 <LoadingButton
582 onclick={handleSubmit}
583 disabled={!currentUser || Boolean(cooldownEndTime)}
584 loading={isSendingMessage}
585 text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : "Envoyer"}
586 />
587 </div>
588 {#if cooldownEndTime}
589 <div class="cooldown-progress" style="width: {100 - (cooldownProgress * 100 / (cooldownEndTime - Date.now()) * 1000)}%"></div>
590 {/if}
591 </div>
592
593 <!-- Online users list -->
594 <div
595 class="sunken-panel users-list"
596 class:mobile={isMobile}
597 class:hidden={isMobile && !showUserList}
598 style="width: {isMobile ? '100%' : '9.375rem'}; padding: 0.5rem; overflow-y: auto;"
599 >
600 <p style="margin: 0 0 0.3rem 0;"><strong>{totalUsers} membre{totalUsers > 1 ? 's' : ''}</strong></p>
601 {#each onlineUsers as user}
602 <div class="user" class:offline={user.status === 'offline'}>
603 <span class="status-icon">{getStatusIcon(user.status)}</span>
604 <div class="user-info">
605 {#if user.status === 'offline'}
606 <Tooltip data={{
607 text: "Dernière connexion: " + formatFrenchRelativeTimeSafe(user.lastSeen),
608 direction: "bottom",
609 closeDelay: 1000,
610 touchBehavior: "remove"
611 }}>
612 <div class="nickname select-none">{user.nickname}</div>
613 </Tooltip>
614 {:else}
615 <div class="nickname select-none">{user.nickname}</div>
616 {/if}
617 </div>
618 </div>
619 {/each}
620 </div>
621 </div>
622 {/if}
623</div>
624{/if}
625
626{#if showAuth}
627 <AimLogin
628 bind:showAuth
629 activeTab={'signup' as const}
630 onLoginSuccess={handleLoginSuccess}
631 />
632{/if}
633
634<style>
635 .chat-area, .users-list, input {
636 font-size: 1rem;
637 font-family: Arial, Verdana, Tahoma, sans-serif;
638 }
639
640 .title-bar {
641 height: 2rem;
642 position: relative;
643 cursor: default;
644 user-select: none;
645 display: flex;
646 align-items: center;
647 padding: 0 0.5rem;
648 }
649
650 .title-bar:not(:has(button:hover)) {
651 cursor: move;
652 }
653
654 .chat-window {
655 position: fixed;
656 box-sizing: border-box;
657 transition: width 0.3s ease, height 0.3s ease, left 0.3s ease, top 0.3s ease;
658 will-change: transform;
659 }
660
661 .chat-window.dragging {
662 transition: none !important;
663 user-select: none;
664 }
665
666 .chat-area {
667 line-height: 1.4;
668 }
669
670 .message {
671 margin-bottom: 0.25rem;
672 word-break: break-word;
673 display: flex;
674 gap: 0.5rem;
675 align-items: flex-start;
676 }
677
678 .message .nickname {
679 font-weight: bold;
680 color: #2d31a6;
681 cursor: help;
682 }
683
684 .message .content {
685 flex: 1;
686 }
687
688 .message.emote {
689 color: #666;
690 font-style: italic;
691 }
692
693 .message.emote .nickname {
694 color: #666;
695 }
696
697 .user {
698 display: flex;
699 align-items: flex-start;
700 margin-bottom: 0.25rem;
701 gap: 0.375rem;
702 transition: opacity 0.3s ease;
703 }
704
705 .user.offline {
706 opacity: 0.6;
707 }
708
709 .status-icon {
710 font-size: 0.875rem;
711 transition: color 0.3s ease;
712 }
713
714 .user-info {
715 flex: 1;
716 min-width: 0;
717 }
718
719 .user .nickname {
720 font-weight: bold;
721 margin-bottom: 0.125rem;
722 transition: color 0.3s ease;
723 }
724
725 .user.offline .nickname {
726 color: #666;
727 }
728
729 /* .user .status-message {
730 font-size: 0.875rem;
731 color: #666;
732 font-style: italic;
733 overflow: hidden;
734 text-overflow: ellipsis;
735 white-space: nowrap;
736 } */
737
738 .sunken-panel {
739 background: white;
740 border: 0.125rem inset #dfdfdf;
741 }
742
743 input[type="text"] {
744 font-size: 1rem;
745 margin-right: 0.25rem;
746 }
747
748 button {
749 font-size: 1rem;
750 }
751
752 .chat-container {
753 position: relative;
754 z-index: 1;
755 }
756
757 .input-container {
758 position: relative;
759 z-index: 2;
760 }
761
762 .input-container input:disabled,
763 .input-container {
764 opacity: 0.7;
765 background-color: rgba(128, 128, 128, 0.1);
766 cursor: not-allowed;
767 }
768
769 .hidden {
770 display: none !important;
771 }
772
773 :global(.resize-handle) {
774 position: absolute;
775 bottom: 0;
776 right: 0;
777 width: 15px !important;
778 height: 15px !important;
779 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");
780 background-position: bottom right;
781 background-repeat: no-repeat;
782 cursor: se-resize !important;
783 }
784
785 @media (max-width: 768px) {
786 .window {
787 position: fixed !important;
788 top: 0 !important;
789 left: 0 !important;
790 right: 0 !important;
791 bottom: 0 !important;
792 width: 100% !important;
793 height: 100% !important;
794 transition: transform 0.3s ease !important;
795 margin: 0 !important;
796 border-radius: 0 !important;
797 }
798
799 .window.minimized {
800 transform: translateY(calc(100% - 32px)) !important;
801 height: 100% !important;
802 }
803
804 .window.minimized .title-bar {
805 height: 32px;
806 }
807
808 .title-bar-controls button {
809 width: 24px;
810 height: 24px;
811 padding: 0;
812 margin: 0 2px;
813 background-color: transparent;
814 border: 1px solid transparent;
815 position: relative;
816 }
817
818 .title-bar-controls button.contacts-btn {
819 width: auto;
820 padding: 0 0.5rem;
821 font-size: 1.25rem;
822 }
823
824 .window.minimized .title-bar-controls button {
825 opacity: 0.8;
826 }
827
828 .window.minimized .title-bar-controls button.contacts-btn {
829 opacity: 0.8;
830 }
831
832 .window-body {
833 flex-direction: column;
834 }
835
836 .chat-container {
837 margin-right: 0 !important;
838 margin-bottom: 0 !important;
839 height: 100%;
840 }
841
842 .users-list.mobile {
843 position: fixed;
844 bottom: 0;
845 left: 0;
846 right: 0;
847 height: auto;
848 max-height: 30vh;
849 z-index: 1000;
850 border-top: 0.125rem solid #dfdfdf;
851 background: white;
852 }
853
854 .input-container {
855 padding: 0.5rem;
856 border-top: 0.125rem solid #dfdfdf;
857 }
858 }
859
860 .relative-time {
861 color: #666;
862 font-size: 0.75rem;
863 margin-right: 0.5rem;
864 font-style: italic;
865 }
866
867 .cooldown-progress {
868 position: absolute;
869 bottom: 0;
870 left: 0;
871 height: 2px;
872 background: #2d31a6;
873 transition: width 0.1s linear;
874 }
875
876 .input-container {
877 position: relative;
878 }
879
880 .connection-error {
881 font-size: 0.75rem;
882 color: #ff4444;
883 margin-left: 0.5rem;
884 }
885
886 .error-banner {
887 position: absolute;
888 top: 2rem;
889 left: 0;
890 right: 0;
891 background: #ffebee;
892 color: #c62828;
893 padding: 0.5rem;
894 text-align: center;
895 z-index: 100;
896 border-bottom: 1px solid #ffcdd2;
897 font-size: 0.875rem;
898 }
899
900 .error-banner small {
901 color: #666;
902 }
903
904 .rate-limit-warning {
905 background: #fff3e0;
906 color: #e65100;
907 padding: 0.5rem;
908 margin-bottom: 0.5rem;
909 border-radius: 4px;
910 font-size: 0.875rem;
911 display: flex;
912 flex-direction: column;
913 gap: 0.25rem;
914 animation: slideIn 0.3s ease-out;
915 border: 1px solid #ffe0b2;
916 }
917
918 .rate-limit-warning.with-progress {
919 border-left: 4px solid #e65100;
920 }
921
922 .rate-limit-warning small {
923 color: #666;
924 font-size: 0.75rem;
925 }
926
927 @keyframes slideIn {
928 from {
929 transform: translateY(-100%);
930 opacity: 0;
931 }
932 to {
933 transform: translateY(0);
934 opacity: 1;
935 }
936 }
937
938 .cooldown-progress {
939 position: absolute;
940 bottom: 0;
941 left: 0;
942 height: 2px;
943 background: #e65100;
944 transition: width 0.1s linear;
945 }
946
947 .initial-loading {
948 display: flex;
949 justify-content: center;
950 align-items: center;
951 height: 100%;
952 font-size: 1.1rem;
953 color: #666;
954 }
955
956 .loading-messages {
957 text-align: center;
958 padding: 1rem;
959 color: #666;
960 font-style: italic;
961 background: rgba(0, 0, 0, 0.05);
962 margin-bottom: 1rem;
963 border-radius: 4px;
964 }
965
966 .registration-prompt {
967 background: #fff3e0;
968 color: #e65100;
969 padding: 1rem;
970 margin-bottom: 1rem;
971 border-radius: 4px;
972 text-align: center;
973 font-size: 0.95rem;
974 border: 1px solid #ffe0b2;
975 animation: fadeIn 0.3s ease-out;
976 }
977
978 .registration-prompt p {
979 margin: 0;
980 }
981
982 @keyframes fadeIn {
983 from {
984 opacity: 0;
985 transform: translateY(-10px);
986 }
987 to {
988 opacity: 1;
989 transform: translateY(0);
990 }
991 }
992
993 .registration-prompt .link-button {
994 color: #e65100;
995 text-decoration: underline;
996 cursor: pointer;
997 background: none;
998 border: none;
999 padding: 0;
1000 font: inherit;
1001 }
1002
1003 .registration-prompt .link-button:hover {
1004 color: #ef6c00;
1005 text-decoration: none;
1006 }
1007
1008 .chat-window.minimized {
1009 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
1010 }
1011
1012 .minimized .title-bar {
1013 cursor: pointer;
1014 }
1015
1016 .minimized .window-body {
1017 display: none;
1018 }
1019
1020 .minimized :global(.resize-handle) {
1021 display: none;
1022 }
1023
1024 .title-bar-text {
1025 flex: 1;
1026 min-width: 0;
1027 padding-right: 0.5rem;
1028 }
1029
1030 .title-bar-controls {
1031 display: flex;
1032 gap: 2px;
1033 margin-left: auto;
1034 }
1035
1036 .pointer-events-none {
1037 pointer-events: none;
1038 }
1039</style>