message grouping

This commit is contained in:
2026-04-05 21:12:58 -04:00
parent 6d6abbdf94
commit fc7e4487b8
6 changed files with 124 additions and 30 deletions
+40
View File
@@ -627,6 +627,46 @@ body {
position: relative; position: relative;
} }
.message-item.grouped {
padding-top: 4px;
padding-bottom: 4px;
}
.message-item.grouped .message-header {
display: none;
}
.message-avatar-placeholder {
width: 40px;
height: 20px; /* Match approximate line-height */
flex-shrink: 0;
position: relative;
display: flex;
justify-content: center;
}
.thread-message-item .message-avatar-placeholder {
width: 32px;
}
.message-hover-timestamp {
position: absolute;
top: 0;
left: 0;
width: 100%;
text-align: center;
font-size: 0.65rem;
color: var(--text-muted);
opacity: 0;
pointer-events: none;
line-height: 1.4;
padding-top: 2px;
}
.message-item.grouped:hover .message-hover-timestamp {
opacity: 1;
}
.message-item:hover { .message-item:hover {
background-color: rgba(0, 0, 0, 0.05); background-color: rgba(0, 0, 0, 0.05);
} }
+2 -2
View File
@@ -99,7 +99,7 @@
<g class="zep-blimp"> <g class="zep-blimp">
<!-- Fins / Comic Action Lines --> <!-- Fins / Comic Action Lines -->
<path d="M15 40 L5 35 M15 50 L2 50 M15 60 L5 65" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.4" /> <path d="M15 40 L5 35 M15 50 L2 50 M15 60 L5 65" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.4" />
<!-- The Chat Bubble Zeppelin Sandwich --> <!-- The Chat Bubble Zeppelin Sandwich -->
<!-- Top Bun --> <!-- Top Bun -->
<path d="M25 50 C25 30 45 25 60 25 C85 25 95 35 95 50" class="blimp-top" /> <path d="M25 50 C25 30 45 25 60 25 C85 25 95 35 95 50" class="blimp-top" />
@@ -107,7 +107,7 @@
<path d="M22 50 C22 50 50 50 98 50" class="blimp-filling" /> <path d="M22 50 C22 50 50 50 98 50" class="blimp-filling" />
<!-- Bottom Bun + Chat Pointer (Gondola) --> <!-- Bottom Bun + Chat Pointer (Gondola) -->
<path d="M95 50 C95 65 85 75 60 75 C55 75 50 75 45 85 L38 75 C30 75 25 65 25 50" class="blimp-bottom" /> <path d="M95 50 C95 65 85 75 60 75 C55 75 50 75 45 85 L38 75 C30 75 25 65 25 50" class="blimp-bottom" />
<!-- Internal "Z" detail --> <!-- Internal "Z" detail -->
<path d="M55 40 L70 40 L55 60 L70 60" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.3" /> <path d="M55 40 L70 40 L55 60 L70 60" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.3" />
</g> </g>
+27 -17
View File
@@ -13,11 +13,13 @@
msg, msg,
isThread = false, isThread = false,
isHighlighted = false, isHighlighted = false,
isGrouped = false,
onContentLoad onContentLoad
}: { }: {
msg: Types.Message; msg: Types.Message;
isThread?: boolean; isThread?: boolean;
isHighlighted?: boolean; isHighlighted?: boolean;
isGrouped?: boolean;
onContentLoad?: () => void; onContentLoad?: () => void;
} = $props(); } = $props();
@@ -145,7 +147,7 @@
<svelte:window onclick={handleGlobalClick} /> <svelte:window onclick={handleGlobalClick} />
<div class="message-item {isHighlighted ? 'active' : ''} {isThread ? 'thread-message-item' : ''}"> <div class="message-item {isHighlighted ? 'active' : ''} {isThread ? 'thread-message-item' : ''} {isGrouped ? 'grouped' : ''}">
<div class="message-actions-toolbar"> <div class="message-actions-toolbar">
{#if chat.isFullyAuthenticated} {#if chat.isFullyAuthenticated}
<div class="add-reaction-wrapper" style="position: relative;"> <div class="add-reaction-wrapper" style="position: relative;">
@@ -186,17 +188,25 @@
{/if} {/if}
</div> </div>
<!-- svelte-ignore a11y_no_static_element_interactions --> {#if isGrouped}
<div oncontextmenu={(e) => { <div class="message-avatar-placeholder">
e.preventDefault(); <div class="message-hover-timestamp">
e.stopPropagation(); {chat.formatTime(msg.sent)}
const user = chat.users.find(u => u.identity.isEqual(msg.sender)); </div>
if (user) { </div>
chat.userContextMenu = { x: e.clientX, y: e.clientY, user }; {:else}
} <!-- svelte-ignore a11y_no_static_element_interactions -->
}}> <div oncontextmenu={(e) => {
<Avatar user={chat.users.find(u => u.identity.isEqual(msg.sender))} size={isThread ? "small" : "medium"} class="message-avatar" /> e.preventDefault();
</div> e.stopPropagation();
const user = chat.users.find(u => u.identity.isEqual(msg.sender));
if (user) {
chat.userContextMenu = { x: e.clientX, y: e.clientY, user };
}
}}>
<Avatar user={chat.users.find(u => u.identity.isEqual(msg.sender))} size={isThread ? "small" : "medium"} class="message-avatar" />
</div>
{/if}
<div class="message-content"> <div class="message-content">
<div class="message-header"> <div class="message-header">
@@ -281,11 +291,11 @@
{/if} {/if}
{/if} {/if}
<span class="count">{group.length}</span> <span class="count">{group.length}</span>
</button> </button>
{/each} {/each}
</div> </div>
{#if !isThread && existingThread} {#if !isThread && existingThread}
{@const threadMessageCount = chat.allMessages.filter(m => m.threadId === existingThread.id).length} {@const threadMessageCount = chat.allMessages.filter(m => m.threadId === existingThread.id).length}
<button <button
class="thread-link" class="thread-link"
@@ -348,4 +358,4 @@
border: 6px solid transparent; border: 6px solid transparent;
border-top-color: var(--background-floating); border-top-color: var(--background-floating);
} }
</style> </style>
+29 -7
View File
@@ -18,7 +18,7 @@
function handleScroll() { function handleScroll() {
if (!scrollContainer) return; if (!scrollContainer) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer; const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
// If we are within 100px of the bottom, consider us "at the bottom" // If we are within 100px of the bottom, consider us "at the bottom"
const atBottom = scrollHeight - scrollTop - clientHeight < 100; const atBottom = scrollHeight - scrollTop - clientHeight < 100;
isAtBottom = atBottom; isAtBottom = atBottom;
@@ -72,7 +72,7 @@
// If the user is NOT at the bottom, adjust scroll to keep position // If the user is NOT at the bottom, adjust scroll to keep position
if (!isAtBottom && !isPrepending && !sentByMe) { if (!isAtBottom && !isPrepending && !sentByMe) {
scrollContainer!.scrollTop += heightDiff; scrollContainer!.scrollTop += heightDiff;
} }
// If we ARE at the bottom, OR we just sent a message, keep us pinned // If we ARE at the bottom, OR we just sent a message, keep us pinned
else if (isAtBottom || sentByMe) { else if (isAtBottom || sentByMe) {
scrollContainer!.scrollTop = target.scrollHeight; scrollContainer!.scrollTop = target.scrollHeight;
@@ -100,7 +100,7 @@
} }
$effect(() => { $effect(() => {
const messages = chat.channelMessages; const messagesLocal = chat.channelMessages;
const currentChannelId = chat.activeChannelId; const currentChannelId = chat.activeChannelId;
if (isPrepending && scrollContainer) { if (isPrepending && scrollContainer) {
@@ -109,13 +109,13 @@
if (diff > 0) { if (diff > 0) {
scrollContainer.scrollTop = diff; scrollContainer.scrollTop = diff;
isPrepending = false; isPrepending = false;
lastHeight = newScrollHeight; lastHeight = newScrollHeight;
} }
} }
if (messages.length > 0 && scrollContainer) { if (messagesLocal.length > 0 && scrollContainer) {
const isChannelSwitch = currentChannelId !== lastChannelId; const isChannelSwitch = currentChannelId !== lastChannelId;
const lastMsg = messages[messages.length - 1]; const lastMsg = messagesLocal[messagesLocal.length - 1];
const myIdHex = chat.identity?.toHexString(); const myIdHex = chat.identity?.toHexString();
const lastMsgSenderHex = lastMsg?.sender.toHexString(); const lastMsgSenderHex = lastMsg?.sender.toHexString();
const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex; const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex;
@@ -133,6 +133,27 @@
} }
} }
}); });
const messages = $derived(chat.channelMessages);
const messageGroups = $derived.by(() => {
return messages.map((msg, index) => {
if (index === 0) return false;
const prevMsg = messages[index - 1];
try {
const sameSender = msg.sender.toHexString() === prevMsg.sender.toHexString();
const sameThread = msg.threadId === prevMsg.threadId;
const diff = msg.sent.microsSinceUnixEpoch - prevMsg.sent.microsSinceUnixEpoch;
// Ensure non-negative and within 5 mins (300,000,000 micros)
const withinFiveMinutes = diff >= 0n && diff < 300000000n;
return sameSender && sameThread && withinFiveMinutes;
} catch {
return false;
}
});
});
</script> </script>
<div <div
@@ -144,11 +165,12 @@
bind:this={scrollContainer} bind:this={scrollContainer}
onscroll={handleScroll} onscroll={handleScroll}
> >
{#each chat.channelMessages as msg (msg.id.toString())} {#each messages as msg, i (msg.id.toString())}
{@const isHighlighted = (chat.pendingThreadParentMessageId === msg.id) || (chat.activeThread?.parentMessageId === msg.id)} {@const isHighlighted = (chat.pendingThreadParentMessageId === msg.id) || (chat.activeThread?.parentMessageId === msg.id)}
<MessageItem <MessageItem
{msg} {msg}
{isHighlighted} {isHighlighted}
isGrouped={messageGroups[i]}
onContentLoad={handleContentLoad} onContentLoad={handleContentLoad}
/> />
{/each} {/each}
+23 -1
View File
@@ -9,13 +9,35 @@
threadMessages: readonly Types.Message[], threadMessages: readonly Types.Message[],
onContentLoad?: () => void onContentLoad?: () => void
} = $props(); } = $props();
const messages = $derived(threadMessages);
const messageGroups = $derived.by(() => {
return messages.map((msg, index) => {
if (index === 0) return false;
const prevMsg = messages[index - 1];
try {
const sameSender = msg.sender.toHexString() === prevMsg.sender.toHexString();
const sameThread = msg.threadId === prevMsg.threadId;
const diff = msg.sent.microsSinceUnixEpoch - prevMsg.sent.microsSinceUnixEpoch;
// Ensure non-negative and within 5 mins (300,000,000 micros)
const withinFiveMinutes = diff >= 0n && diff < 300000000n;
return sameSender && sameThread && withinFiveMinutes;
} catch {
return false;
}
});
});
</script> </script>
<div class="thread-messages-inner" style="display: flex; flex-direction: column;"> <div class="thread-messages-inner" style="display: flex; flex-direction: column;">
{#each threadMessages as msg (msg.id.toString())} {#each messages as msg, i (msg.id.toString())}
<MessageItem <MessageItem
{msg} {msg}
isThread={true} isThread={true}
isGrouped={messageGroups[i]}
{onContentLoad} {onContentLoad}
/> />
{/each} {/each}
+3 -3
View File
@@ -171,7 +171,7 @@ export class MessagingService {
if (!isExpanded) { if (!isExpanded) {
// In non-expanded mode, strictly ONLY show the cache for THIS channel // In non-expanded mode, strictly ONLY show the cache for THIS channel
return mappedRecent.sort((a, b) => return mappedRecent.sort((a, b) =>
Number(BigInt(a.sent.microsSinceUnixEpoch) - BigInt(b.sent.microsSinceUnixEpoch)) a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : (a.sent.microsSinceUnixEpoch > b.sent.microsSinceUnixEpoch ? 1 : 0)
); );
} }
@@ -191,7 +191,7 @@ export class MessagingService {
} }
return Array.from(msgMap.values()).sort((a, b) => return Array.from(msgMap.values()).sort((a, b) =>
Number(BigInt(a.sent.microsSinceUnixEpoch) - BigInt(b.sent.microsSinceUnixEpoch)) a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : (a.sent.microsSinceUnixEpoch > b.sent.microsSinceUnixEpoch ? 1 : 0)
); );
} }
@@ -243,7 +243,7 @@ export class MessagingService {
} }
return Array.from(msgMap.values()).sort((a, b) => return Array.from(msgMap.values()).sort((a, b) =>
Number(BigInt(a.sent.microsSinceUnixEpoch) - BigInt(b.sent.microsSinceUnixEpoch)) a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : (a.sent.microsSinceUnixEpoch > b.sent.microsSinceUnixEpoch ? 1 : 0)
); );
} }