message grouping
This commit is contained in:
+40
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user