v0.1 64c03e0

chat-room.svelte RawHistoryFolder

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>

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