|
1 | 1 | <script lang="ts"> |
2 | 2 | import { onDestroy, onMount } from 'svelte'; |
3 | | - import { ArrowLeft, Send, Users, Settings, Pencil, Check, X, SmilePlus, Reply } from '@lucide/svelte'; |
| 3 | + import { ArrowLeft, Send, Users, Settings, Pencil, Check, X, SmilePlus, Reply, Bold, Italic, Code, Link, List, SquareCode } from '@lucide/svelte'; |
4 | 4 | import { unreadCount } from '$lib/stores/unreadCount.svelte'; |
| 5 | + import { browser } from '$app/environment'; |
| 6 | +
|
| 7 | + // Both renderMarkdown (Prism) and DOMPurify require browser globals — lazy-load them |
| 8 | + let renderMarkdown: ((content: string) => string) | null = $state(null); |
| 9 | + let DOMPurify: any = $state(null); |
| 10 | + if (browser) { |
| 11 | + Promise.all([ |
| 12 | + import('$lib/markdown/helper-funcs'), |
| 13 | + import('dompurify') |
| 14 | + ]).then(([mdModule, dpModule]) => { |
| 15 | + renderMarkdown = mdModule.renderMarkdown; |
| 16 | + DOMPurify = dpModule.default; |
| 17 | + }); |
| 18 | + } |
5 | 19 |
|
6 | 20 | const EMOJI_CHOICES = ['👍', '❤️', '😂', '😮', '😢', '🔥', '👏', '🎉']; |
7 | 21 |
|
|
31 | 45 | let mentionStartIndex = $state(0); |
32 | 46 | let selectedMentionIndex = $state(0); |
33 | 47 | let mentionedUserIds: string[] = $state([]); |
34 | | - let messageInputEl: HTMLInputElement | undefined = $state(); |
| 48 | + let messageInputEl: HTMLTextAreaElement | undefined = $state(); |
35 | 49 |
|
36 | 50 | let filteredParticipants = $derived( |
37 | 51 | data.participants.filter((p: any) => { |
|
129 | 143 | } |
130 | 144 |
|
131 | 145 | // Fetch reactions for all loaded messages on mount |
132 | | - async function loadReactions() { |
133 | | - const promises = messages.map(async (msg) => { |
134 | | - try { |
135 | | - const res = await fetch(`/api/chat/${data.chatRoom.chat_room_id}/messages/${msg.chat_message_id}/reactions`); |
136 | | - if (!res.ok) return; |
137 | | - const result = await res.json(); |
138 | | - if (result.reactions && result.reactions.length > 0) { |
139 | | - reactions[msg.chat_message_id] = result.reactions.map((r: any) => ({ |
140 | | - emoji: r.emoji, |
141 | | - user_id: r.user_id, |
142 | | - username: r.username |
143 | | - })); |
| 146 | + /** |
| 147 | + * Populate the reactions state from the reaction summaries already |
| 148 | + * embedded in the messages response (no extra API calls needed). |
| 149 | + */ |
| 150 | + function loadReactionsFromMessages() { |
| 151 | + for (const msg of messages) { |
| 152 | + if (msg.reactions && msg.reactions.length > 0) { |
| 153 | + const flat: Array<{emoji: string, user_id: string, username: string}> = []; |
| 154 | + for (const r of msg.reactions) { |
| 155 | + for (const uid of r.user_ids || []) { |
| 156 | + flat.push({ emoji: r.emoji, user_id: uid, username: '' }); |
| 157 | + } |
144 | 158 | } |
145 | | - } catch { |
146 | | - // Silently ignore |
| 159 | + reactions[msg.chat_message_id] = flat; |
147 | 160 | } |
148 | | - }); |
149 | | - await Promise.all(promises); |
| 161 | + } |
150 | 162 | } |
151 | 163 |
|
152 | 164 | async function toggleReaction(messageId: string, emoji: string) { |
|
283 | 295 |
|
284 | 296 | onMount(() => { |
285 | 297 | connectSSE(); |
286 | | - loadReactions(); |
| 298 | + loadReactionsFromMessages(); |
287 | 299 | // Mark as read on initial load (mouse is likely already in the area) |
288 | 300 | markAsReadIfNeeded(); |
289 | 301 | window.addEventListener('focus', handleWindowFocus); |
|
327 | 339 | appendMessage(result); |
328 | 340 | messageContent = ''; |
329 | 341 | replyingTo = null; |
| 342 | + if (messageInputEl) messageInputEl.style.height = 'auto'; |
330 | 343 | mentionedUserIds = []; |
331 | 344 | } catch { |
332 | 345 | errorMessage = 'Failed to send message. Please try again.'; |
|
435 | 448 | }, 0); |
436 | 449 | } |
437 | 450 |
|
438 | | - function handleMentionKeydown(event: KeyboardEvent) { |
439 | | - if (!showMentionDropdown || filteredParticipants.length === 0) return; |
| 451 | + function handleInputKeydown(event: KeyboardEvent) { |
| 452 | + // Mention dropdown takes priority when open |
| 453 | + if (showMentionDropdown && filteredParticipants.length > 0) { |
| 454 | + if (event.key === 'ArrowDown') { |
| 455 | + event.preventDefault(); |
| 456 | + selectedMentionIndex = (selectedMentionIndex + 1) % filteredParticipants.length; |
| 457 | + return; |
| 458 | + } else if (event.key === 'ArrowUp') { |
| 459 | + event.preventDefault(); |
| 460 | + selectedMentionIndex = (selectedMentionIndex - 1 + filteredParticipants.length) % filteredParticipants.length; |
| 461 | + return; |
| 462 | + } else if (event.key === 'Enter' || event.key === 'Tab') { |
| 463 | + event.preventDefault(); |
| 464 | + insertMention(filteredParticipants[selectedMentionIndex]); |
| 465 | + return; |
| 466 | + } else if (event.key === 'Escape') { |
| 467 | + showMentionDropdown = false; |
| 468 | + return; |
| 469 | + } |
| 470 | + } |
440 | 471 |
|
441 | | - if (event.key === 'ArrowDown') { |
442 | | - event.preventDefault(); |
443 | | - selectedMentionIndex = (selectedMentionIndex + 1) % filteredParticipants.length; |
444 | | - } else if (event.key === 'ArrowUp') { |
445 | | - event.preventDefault(); |
446 | | - selectedMentionIndex = (selectedMentionIndex - 1 + filteredParticipants.length) % filteredParticipants.length; |
447 | | - } else if (event.key === 'Enter' || event.key === 'Tab') { |
| 472 | + // Enter sends, Shift+Enter inserts newline |
| 473 | + if (event.key === 'Enter' && !event.shiftKey) { |
448 | 474 | event.preventDefault(); |
449 | | - insertMention(filteredParticipants[selectedMentionIndex]); |
450 | | - } else if (event.key === 'Escape') { |
451 | | - showMentionDropdown = false; |
| 475 | + if (messageContent.trim() && !sending) { |
| 476 | + messageInputEl?.form?.requestSubmit(); |
| 477 | + } |
| 478 | + } |
| 479 | + } |
| 480 | +
|
| 481 | + // Auto-resize textarea to fit content |
| 482 | + function autoResize() { |
| 483 | + if (messageInputEl) { |
| 484 | + messageInputEl.style.height = 'auto'; |
| 485 | + messageInputEl.style.height = Math.min(messageInputEl.scrollHeight, 150) + 'px'; |
452 | 486 | } |
453 | 487 | } |
454 | 488 |
|
455 | 489 | // Parse message content into segments for rendering mentions |
456 | | - function parseMessageContent(message: any): Array<{type: 'text' | 'mention', text: string}> { |
| 490 | + /** |
| 491 | + * Render a chat message as sanitized HTML with markdown and @mention highlighting. |
| 492 | + * Markdown is rendered first, then @mentions are highlighted in the HTML text nodes. |
| 493 | + */ |
| 494 | + function renderChatMessage(message: any, isOwn: boolean): string { |
457 | 495 | const content = message.content || ''; |
458 | | - const mentionIds: string[] = message.mentioned_user_ids || []; |
459 | | - if (mentionIds.length === 0) return [{ type: 'text', text: content }]; |
| 496 | + if (!content) return ''; |
460 | 497 |
|
461 | | - // Build a set of usernames for mentioned users |
462 | | - const mentionedUsernames = new Set<string>(); |
463 | | - for (const uid of mentionIds) { |
464 | | - const p = data.participants.find((p: any) => p.user_id === uid); |
465 | | - if (p) mentionedUsernames.add(p.username || p.user_id); |
| 498 | + // Before markdown/DOMPurify are loaded, show plain text (escaped) |
| 499 | + if (!renderMarkdown || !DOMPurify) { |
| 500 | + const escaped = content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
| 501 | + return `<p>${escaped}</p>`; |
466 | 502 | } |
467 | | - if (mentionedUsernames.size === 0) return [{ type: 'text', text: content }]; |
468 | 503 |
|
469 | | - // Build regex to match @username for any mentioned user |
470 | | - const escaped = [...mentionedUsernames].map(u => u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); |
471 | | - const regex = new RegExp(`(@(?:${escaped.join('|')}))(?=\\s|$)`, 'g'); |
| 504 | + // Render markdown to HTML, then make links open in new tabs |
| 505 | + let html = renderMarkdown(content) |
| 506 | + .replace(/<a href="/g, '<a target="_blank" rel="noopener noreferrer" href="'); |
472 | 507 |
|
473 | | - const segments: Array<{type: 'text' | 'mention', text: string}> = []; |
474 | | - let lastIndex = 0; |
475 | | - let match: RegExpExecArray | null; |
476 | | -
|
477 | | - while ((match = regex.exec(content)) !== null) { |
478 | | - if (match.index > lastIndex) { |
479 | | - segments.push({ type: 'text', text: content.slice(lastIndex, match.index) }); |
| 508 | + // Highlight @mentions in the rendered HTML |
| 509 | + const mentionIds: string[] = message.mentioned_user_ids || []; |
| 510 | + if (mentionIds.length > 0) { |
| 511 | + const mentionedUsernames = new Set<string>(); |
| 512 | + for (const uid of mentionIds) { |
| 513 | + const p = data.participants.find((p: any) => p.user_id === uid); |
| 514 | + if (p) mentionedUsernames.add(p.username || p.user_id); |
| 515 | + } |
| 516 | + if (mentionedUsernames.size > 0) { |
| 517 | + const escaped = [...mentionedUsernames].map(u => u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); |
| 518 | + const mentionRegex = new RegExp(`(@(?:${escaped.join('|')}))(?=\\s|[<&]|$)`, 'g'); |
| 519 | + const mentionClass = isOwn ? 'bg-white/20' : 'bg-primary-500/20'; |
| 520 | + html = html.replace(mentionRegex, `<span class="font-semibold ${mentionClass} rounded px-0.5">$1</span>`); |
480 | 521 | } |
481 | | - segments.push({ type: 'mention', text: match[1] }); |
482 | | - lastIndex = regex.lastIndex; |
483 | | - } |
484 | | - if (lastIndex < content.length) { |
485 | | - segments.push({ type: 'text', text: content.slice(lastIndex) }); |
486 | 522 | } |
487 | 523 |
|
488 | | - return segments.length > 0 ? segments : [{ type: 'text', text: content }]; |
| 524 | + // Sanitize to prevent XSS — allow class attributes for styling |
| 525 | + if (!DOMPurify) return html; // SSR fallback — will be re-rendered client-side with sanitization |
| 526 | + return DOMPurify.sanitize(html, { |
| 527 | + ADD_ATTR: ['class'], |
| 528 | + ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', |
| 529 | + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'span', 'del', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td'], |
| 530 | + ALLOWED_ATTR: ['href', 'target', 'rel', 'class'] |
| 531 | + }); |
489 | 532 | } |
490 | 533 |
|
491 | 534 | // --- Read marker logic --- |
|
505 | 548 | lastMarkedReadAt = latestTimestamp; |
506 | 549 | if (!roomCountCleared) { |
507 | 550 | // First read in this room — subtract this room's unread from the header badge |
508 | | - unreadCount.clearRoom(data.roomUnreadCount || 0); |
| 551 | + unreadCount.set(0); |
509 | 552 | roomCountCleared = true; |
510 | 553 | } |
511 | 554 | fetch(`/api/chat/${data.chatRoom.chat_room_id}/read-marker`, { method: 'PUT' }); |
|
527 | 570 | } |
528 | 571 | } |
529 | 572 |
|
| 573 | + // Auto-resize edit textarea on mount to fit existing content |
| 574 | + function autoResizeEdit(node: HTMLTextAreaElement) { |
| 575 | + setTimeout(() => { |
| 576 | + node.style.height = 'auto'; |
| 577 | + node.style.height = Math.min(node.scrollHeight, 300) + 'px'; |
| 578 | + node.focus(); |
| 579 | + node.setSelectionRange(node.value.length, node.value.length); |
| 580 | + }, 0); |
| 581 | + } |
| 582 | +
|
| 583 | + // --- Markdown formatting toolbar --- |
| 584 | + function insertFormatting(before: string, after: string, placeholder: string) { |
| 585 | + if (!messageInputEl) return; |
| 586 | + const start = messageInputEl.selectionStart || 0; |
| 587 | + const end = messageInputEl.selectionEnd || 0; |
| 588 | + const selected = messageContent.slice(start, end); |
| 589 | + const text = selected || placeholder; |
| 590 | + const newContent = messageContent.slice(0, start) + before + text + after + messageContent.slice(end); |
| 591 | + messageContent = newContent; |
| 592 | + // Place cursor after inserted text (or select the placeholder) |
| 593 | + setTimeout(() => { |
| 594 | + if (messageInputEl) { |
| 595 | + messageInputEl.focus(); |
| 596 | + if (selected) { |
| 597 | + const pos = start + before.length + text.length + after.length; |
| 598 | + messageInputEl.setSelectionRange(pos, pos); |
| 599 | + } else { |
| 600 | + messageInputEl.setSelectionRange(start + before.length, start + before.length + text.length); |
| 601 | + } |
| 602 | + } |
| 603 | + }, 0); |
| 604 | + } |
| 605 | +
|
530 | 606 | // Close emoji picker when clicking outside |
531 | 607 | function handleWindowClick(event: MouseEvent) { |
532 | 608 | const target = event.target as HTMLElement; |
|
670 | 746 | </button> |
671 | 747 | </div> |
672 | 748 | {/if} |
673 | | - <div class="relative max-w-[75%]"> |
| 749 | + <div class="relative {editingMessageId === message.chat_message_id ? 'max-w-full w-full' : 'max-w-[75%]'}"> |
674 | 750 | <!-- Emoji picker popup --> |
675 | 751 | {#if emojiPickerMessageId === message.chat_message_id} |
676 | 752 | <div |
|
717 | 793 | <textarea |
718 | 794 | bind:value={editContent} |
719 | 795 | onkeydown={handleEditKeydown} |
720 | | - class="w-full rounded border border-white/30 bg-white/10 px-2 py-1 text-sm text-inherit focus:outline-none focus:ring-1 focus:ring-white/50" |
721 | | - rows="2" |
| 796 | + oninput={(e) => { const t = e.currentTarget; t.style.height = 'auto'; t.style.height = Math.min(t.scrollHeight, 300) + 'px'; }} |
| 797 | + use:autoResizeEdit |
| 798 | + class="w-full rounded border border-white/30 bg-white/10 px-2 py-1 text-sm text-inherit focus:outline-none focus:ring-1 focus:ring-white/50 resize overflow-auto" |
| 799 | + rows="3" |
722 | 800 | data-testid="edit-message-input" |
723 | 801 | ></textarea> |
724 | 802 | <div class="flex justify-end gap-1"> |
|
743 | 821 | </div> |
744 | 822 | </div> |
745 | 823 | {:else} |
746 | | - <p class="whitespace-pre-wrap break-words">{#each parseMessageContent(message) as segment}{#if segment.type === 'mention'}<span class="font-semibold {isOwn ? 'bg-white/20' : 'bg-primary-500/20'} rounded px-0.5" data-testid="mention">{segment.text}</span>{:else}{segment.text}{/if}{/each}</p> |
| 824 | + <div class="chat-markdown break-words">{@html renderChatMessage(message, isOwn)}</div> |
747 | 825 | {/if} |
748 | 826 | <p class="mt-1 text-xs opacity-50"> |
749 | 827 | {new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} |
|
821 | 899 | {/each} |
822 | 900 | </div> |
823 | 901 | {/if} |
824 | | - <input |
| 902 | + <textarea |
825 | 903 | bind:this={messageInputEl} |
826 | 904 | name="content" |
827 | | - type="text" |
828 | 905 | bind:value={messageContent} |
829 | | - oninput={handleMessageInput} |
830 | | - onkeydown={handleMentionKeydown} |
831 | | - class="input w-full rounded-md border border-surface-300-600 px-3 py-2" |
832 | | - placeholder={replyingTo ? 'Type your reply...' : 'Type a message...'} |
| 906 | + oninput={() => { handleMessageInput(); autoResize(); }} |
| 907 | + onkeydown={handleInputKeydown} |
| 908 | + class="input w-full rounded-md border border-surface-300-600 px-3 py-2 resize-none overflow-hidden" |
| 909 | + placeholder={replyingTo ? 'Type your reply... (Shift+Enter for new line)' : 'Type a message... (Shift+Enter for new line)'} |
833 | 910 | disabled={sending} |
834 | 911 | autocomplete="off" |
| 912 | + rows="1" |
835 | 913 | data-testid="message-input" |
836 | | - /> |
| 914 | + ></textarea> |
| 915 | + <div class="flex gap-0.5 mt-1" data-testid="formatting-toolbar"> |
| 916 | + <button type="button" onclick={() => insertFormatting('**', '**', 'bold')} title="Bold" class="p-1 rounded text-surface-500 hover:text-surface-700 hover:bg-surface-200-700 transition-colors"> |
| 917 | + <Bold class="size-3.5" /> |
| 918 | + </button> |
| 919 | + <button type="button" onclick={() => insertFormatting('*', '*', 'italic')} title="Italic" class="p-1 rounded text-surface-500 hover:text-surface-700 hover:bg-surface-200-700 transition-colors"> |
| 920 | + <Italic class="size-3.5" /> |
| 921 | + </button> |
| 922 | + <button type="button" onclick={() => insertFormatting('`', '`', 'code')} title="Inline code" class="p-1 rounded text-surface-500 hover:text-surface-700 hover:bg-surface-200-700 transition-colors"> |
| 923 | + <Code class="size-3.5" /> |
| 924 | + </button> |
| 925 | + <button type="button" onclick={() => insertFormatting('```\n', '\n```', 'code block')} title="Code block" class="p-1 rounded text-surface-500 hover:text-surface-700 hover:bg-surface-200-700 transition-colors"> |
| 926 | + <SquareCode class="size-3.5" /> |
| 927 | + </button> |
| 928 | + <button type="button" onclick={() => insertFormatting('[', '](url)', 'link text')} title="Link" class="p-1 rounded text-surface-500 hover:text-surface-700 hover:bg-surface-200-700 transition-colors"> |
| 929 | + <Link class="size-3.5" /> |
| 930 | + </button> |
| 931 | + <button type="button" onclick={() => insertFormatting('- ', '', 'list item')} title="List" class="p-1 rounded text-surface-500 hover:text-surface-700 hover:bg-surface-200-700 transition-colors"> |
| 932 | + <List class="size-3.5" /> |
| 933 | + </button> |
| 934 | + </div> |
837 | 935 | </div> |
838 | 936 | <button |
839 | 937 | type="submit" |
|
0 commit comments