Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit 7e8bbd7

Browse files
committed
chat message layout
1 parent 6732f12 commit 7e8bbd7

3 files changed

Lines changed: 174 additions & 79 deletions

File tree

src/app.css

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
.chat-markdown p { margin: 0.25em 0; }
1919
.chat-markdown p:first-child { margin-top: 0; }
2020
.chat-markdown p:last-child { margin-bottom: 0; }
21-
.chat-markdown pre { margin: 0.5em 0; border-radius: 0.375rem; overflow-x: auto; font-size: 0.85em; }
22-
.chat-markdown code { font-size: 0.85em; padding: 0.1em 0.3em; border-radius: 0.25rem; background: rgba(0,0,0,0.1); }
23-
.chat-markdown pre code { padding: 0; background: none; }
21+
.chat-markdown { overflow-wrap: break-word; word-break: break-word; }
22+
.chat-markdown pre { margin: 0.5em 0; border-radius: 0.375rem; overflow-x: auto; font-size: 0.85em; background: rgba(0,0,0,0.3) !important; color: #e0e0e0; padding: 0.75em; white-space: pre; word-break: normal; overflow-wrap: normal; }
23+
.chat-markdown code { font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; font-size: 0.85em; padding: 0.15em 0.4em; border-radius: 0.25rem; background: rgba(0,0,0,0.2); }
24+
.chat-markdown pre code { padding: 0; background: none; color: inherit; }
2425
.chat-markdown a { text-decoration: underline; }
2526
.chat-markdown ul, .chat-markdown ol { margin: 0.25em 0; padding-left: 1.5em; }
27+
.chat-markdown li { margin: 0.1em 0; }
2628
.chat-markdown blockquote { margin: 0.25em 0; padding-left: 0.75em; border-left: 2px solid currentColor; opacity: 0.7; }
2729
.chat-markdown h1, .chat-markdown h2, .chat-markdown h3 { font-weight: bold; margin: 0.5em 0 0.25em; }
2830
.chat-markdown h1 { font-size: 1.25em; }

src/routes/(protected)/user/chat/[chatRoomId]/+page.server.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,17 @@ export async function load(event: RequestEvent) {
1616
const chatRoomId = event.params.chatRoomId;
1717

1818
try {
19-
const [chatRoom, messagesResponse, participantsResponse, unreadResponse] = await Promise.all([
19+
const [chatRoom, messagesResponse, participantsResponse] = await Promise.all([
2020
obp_requests.get(`/obp/v6.0.0/chat-rooms/${chatRoomId}`, token),
2121
obp_requests.get(`/obp/v6.0.0/chat-rooms/${chatRoomId}/messages`, token),
22-
obp_requests.get(`/obp/v6.0.0/chat-rooms/${chatRoomId}/participants`, token),
23-
obp_requests.get('/obp/v6.0.0/users/current/chat-rooms/unread', token).catch(() => ({ unread_counts: [] }))
22+
obp_requests.get(`/obp/v6.0.0/chat-rooms/${chatRoomId}/participants`, token)
2423
]);
2524

26-
const unreadCounts = unreadResponse.unread_counts || [];
27-
const roomUnread = unreadCounts.find((uc: any) => uc.chat_room_id === chatRoomId);
28-
2925
return {
3026
chatRoom,
3127
messages: messagesResponse.messages || [],
3228
participants: participantsResponse.participants || [],
33-
currentUserId: event.locals.session.data.user?.user_id || '',
34-
roomUnreadCount: roomUnread?.unread_count || 0
29+
currentUserId: event.locals.session.data.user?.user_id || ''
3530
};
3631
} catch (e) {
3732
logger.error('Error fetching chat room:', e);

src/routes/(protected)/user/chat/[chatRoomId]/+page.svelte

Lines changed: 166 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
<script lang="ts">
22
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';
44
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+
}
519
620
const EMOJI_CHOICES = ['👍', '❤️', '😂', '😮', '😢', '🔥', '👏', '🎉'];
721
@@ -31,7 +45,7 @@
3145
let mentionStartIndex = $state(0);
3246
let selectedMentionIndex = $state(0);
3347
let mentionedUserIds: string[] = $state([]);
34-
let messageInputEl: HTMLInputElement | undefined = $state();
48+
let messageInputEl: HTMLTextAreaElement | undefined = $state();
3549
3650
let filteredParticipants = $derived(
3751
data.participants.filter((p: any) => {
@@ -129,24 +143,22 @@
129143
}
130144
131145
// 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+
}
144158
}
145-
} catch {
146-
// Silently ignore
159+
reactions[msg.chat_message_id] = flat;
147160
}
148-
});
149-
await Promise.all(promises);
161+
}
150162
}
151163
152164
async function toggleReaction(messageId: string, emoji: string) {
@@ -283,7 +295,7 @@
283295
284296
onMount(() => {
285297
connectSSE();
286-
loadReactions();
298+
loadReactionsFromMessages();
287299
// Mark as read on initial load (mouse is likely already in the area)
288300
markAsReadIfNeeded();
289301
window.addEventListener('focus', handleWindowFocus);
@@ -327,6 +339,7 @@
327339
appendMessage(result);
328340
messageContent = '';
329341
replyingTo = null;
342+
if (messageInputEl) messageInputEl.style.height = 'auto';
330343
mentionedUserIds = [];
331344
} catch {
332345
errorMessage = 'Failed to send message. Please try again.';
@@ -435,57 +448,87 @@
435448
}, 0);
436449
}
437450
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+
}
440471
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) {
448474
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';
452486
}
453487
}
454488
455489
// 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 {
457495
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 '';
460497
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
501+
return `<p>${escaped}</p>`;
466502
}
467-
if (mentionedUsernames.size === 0) return [{ type: 'text', text: content }];
468503
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="');
472507
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>`);
480521
}
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) });
486522
}
487523
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+
});
489532
}
490533
491534
// --- Read marker logic ---
@@ -505,7 +548,7 @@
505548
lastMarkedReadAt = latestTimestamp;
506549
if (!roomCountCleared) {
507550
// First read in this room — subtract this room's unread from the header badge
508-
unreadCount.clearRoom(data.roomUnreadCount || 0);
551+
unreadCount.set(0);
509552
roomCountCleared = true;
510553
}
511554
fetch(`/api/chat/${data.chatRoom.chat_room_id}/read-marker`, { method: 'PUT' });
@@ -527,6 +570,39 @@
527570
}
528571
}
529572
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+
530606
// Close emoji picker when clicking outside
531607
function handleWindowClick(event: MouseEvent) {
532608
const target = event.target as HTMLElement;
@@ -670,7 +746,7 @@
670746
</button>
671747
</div>
672748
{/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%]'}">
674750
<!-- Emoji picker popup -->
675751
{#if emojiPickerMessageId === message.chat_message_id}
676752
<div
@@ -717,8 +793,10 @@
717793
<textarea
718794
bind:value={editContent}
719795
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"
722800
data-testid="edit-message-input"
723801
></textarea>
724802
<div class="flex justify-end gap-1">
@@ -743,7 +821,7 @@
743821
</div>
744822
</div>
745823
{: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>
747825
{/if}
748826
<p class="mt-1 text-xs opacity-50">
749827
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
@@ -821,19 +899,39 @@
821899
{/each}
822900
</div>
823901
{/if}
824-
<input
902+
<textarea
825903
bind:this={messageInputEl}
826904
name="content"
827-
type="text"
828905
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)'}
833910
disabled={sending}
834911
autocomplete="off"
912+
rows="1"
835913
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>
837935
</div>
838936
<button
839937
type="submit"

0 commit comments

Comments
 (0)