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;
}
.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 {
background-color: rgba(0, 0, 0, 0.05);
}
+2 -2
View File
@@ -99,7 +99,7 @@
<g class="zep-blimp">
<!-- 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" />
<!-- The Chat Bubble Zeppelin Sandwich -->
<!-- Top Bun -->
<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" />
<!-- 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" />
<!-- 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" />
</g>
+27 -17
View File
@@ -13,11 +13,13 @@
msg,
isThread = false,
isHighlighted = false,
isGrouped = false,
onContentLoad
}: {
msg: Types.Message;
isThread?: boolean;
isHighlighted?: boolean;
isGrouped?: boolean;
onContentLoad?: () => void;
} = $props();
@@ -145,7 +147,7 @@
<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">
{#if chat.isFullyAuthenticated}
<div class="add-reaction-wrapper" style="position: relative;">
@@ -186,17 +188,25 @@
{/if}
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div oncontextmenu={(e) => {
e.preventDefault();
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 isGrouped}
<div class="message-avatar-placeholder">
<div class="message-hover-timestamp">
{chat.formatTime(msg.sent)}
</div>
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div oncontextmenu={(e) => {
e.preventDefault();
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-header">
@@ -281,11 +291,11 @@
{/if}
{/if}
<span class="count">{group.length}</span>
</button>
{/each}
</div>
</button>
{/each}
</div>
{#if !isThread && existingThread}
{#if !isThread && existingThread}
{@const threadMessageCount = chat.allMessages.filter(m => m.threadId === existingThread.id).length}
<button
class="thread-link"
@@ -348,4 +358,4 @@
border: 6px solid transparent;
border-top-color: var(--background-floating);
}
</style>
</style>
+29 -7
View File
@@ -18,7 +18,7 @@
function handleScroll() {
if (!scrollContainer) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
// If we are within 100px of the bottom, consider us "at the bottom"
const atBottom = scrollHeight - scrollTop - clientHeight < 100;
isAtBottom = atBottom;
@@ -72,7 +72,7 @@
// If the user is NOT at the bottom, adjust scroll to keep position
if (!isAtBottom && !isPrepending && !sentByMe) {
scrollContainer!.scrollTop += heightDiff;
}
}
// If we ARE at the bottom, OR we just sent a message, keep us pinned
else if (isAtBottom || sentByMe) {
scrollContainer!.scrollTop = target.scrollHeight;
@@ -100,7 +100,7 @@
}
$effect(() => {
const messages = chat.channelMessages;
const messagesLocal = chat.channelMessages;
const currentChannelId = chat.activeChannelId;
if (isPrepending && scrollContainer) {
@@ -109,13 +109,13 @@
if (diff > 0) {
scrollContainer.scrollTop = diff;
isPrepending = false;
lastHeight = newScrollHeight;
lastHeight = newScrollHeight;
}
}
if (messages.length > 0 && scrollContainer) {
if (messagesLocal.length > 0 && scrollContainer) {
const isChannelSwitch = currentChannelId !== lastChannelId;
const lastMsg = messages[messages.length - 1];
const lastMsg = messagesLocal[messagesLocal.length - 1];
const myIdHex = chat.identity?.toHexString();
const lastMsgSenderHex = lastMsg?.sender.toHexString();
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>
<div
@@ -144,11 +165,12 @@
bind:this={scrollContainer}
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)}
<MessageItem
{msg}
{isHighlighted}
isGrouped={messageGroups[i]}
onContentLoad={handleContentLoad}
/>
{/each}
+23 -1
View File
@@ -9,13 +9,35 @@
threadMessages: readonly Types.Message[],
onContentLoad?: () => void
} = $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>
<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
{msg}
isThread={true}
isGrouped={messageGroups[i]}
{onContentLoad}
/>
{/each}
+3 -3
View File
@@ -171,7 +171,7 @@ export class MessagingService {
if (!isExpanded) {
// In non-expanded mode, strictly ONLY show the cache for THIS channel
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) =>
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) =>
Number(BigInt(a.sent.microsSinceUnixEpoch) - BigInt(b.sent.microsSinceUnixEpoch))
a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : (a.sent.microsSinceUnixEpoch > b.sent.microsSinceUnixEpoch ? 1 : 0)
);
}