v0.1.5 0b43029

chat-room.svelte RawHistoryFolder

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>

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