feat(messages): group message feed by channel and thread sections
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { Hash } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { parseMetadata } from "../../../entities/message/types";
|
||||||
import type { MessageRecord } from "../../../shared/api/client";
|
import type { MessageRecord } from "../../../shared/api/client";
|
||||||
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
|
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
|
||||||
import { ScrollArea, EmptyStateMascot } from "../../../shared/ui";
|
import { ScrollArea, EmptyStateMascot } from "../../../shared/ui";
|
||||||
@@ -18,12 +20,12 @@ export interface MessageFeedProps {
|
|||||||
/** Messages from the same user within 5 minutes are visually grouped. */
|
/** Messages from the same user within 5 minutes are visually grouped. */
|
||||||
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
interface MessageGroup {
|
interface UserMessageGroup {
|
||||||
messages: MessageRecord[];
|
messages: MessageRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupMessages(messages: MessageRecord[]): MessageGroup[] {
|
function groupByUser(messages: MessageRecord[]): UserMessageGroup[] {
|
||||||
const groups: MessageGroup[] = [];
|
const groups: UserMessageGroup[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const lastGroup = groups[groups.length - 1];
|
const lastGroup = groups[groups.length - 1];
|
||||||
if (
|
if (
|
||||||
@@ -41,6 +43,57 @@ function groupMessages(messages: MessageRecord[]): MessageGroup[] {
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChannelSection {
|
||||||
|
key: string;
|
||||||
|
channelId: string;
|
||||||
|
threadId: string | null;
|
||||||
|
label: string;
|
||||||
|
groups: UserMessageGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeChannelSections(messages: MessageRecord[]): ChannelSection[] {
|
||||||
|
if (messages.length === 0) return [];
|
||||||
|
|
||||||
|
// Group by (channel_id, thread_id)
|
||||||
|
const map = new Map<string, MessageRecord[]>();
|
||||||
|
for (const msg of messages) {
|
||||||
|
const key = msg.thread_id
|
||||||
|
? `${msg.channel_id}:t:${msg.thread_id}`
|
||||||
|
: msg.channel_id;
|
||||||
|
const list = map.get(key);
|
||||||
|
if (list) list.push(msg);
|
||||||
|
else map.set(key, [msg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: ChannelSection[] = [];
|
||||||
|
for (const [key, channelMessages] of map) {
|
||||||
|
const first = channelMessages[0];
|
||||||
|
const meta = parseMetadata(first.metadata);
|
||||||
|
const ch = meta?.channel;
|
||||||
|
|
||||||
|
let label: string;
|
||||||
|
if (ch?.threadName && ch?.channelName) {
|
||||||
|
label = `# ${ch.channelName} › ${ch.threadName}`;
|
||||||
|
} else if (ch?.channelName) {
|
||||||
|
label = `# ${ch.channelName}`;
|
||||||
|
} else if (first.thread_id) {
|
||||||
|
label = `thread:${first.thread_id.slice(0, 8)}`;
|
||||||
|
} else {
|
||||||
|
label = `# ${first.channel_id.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
key,
|
||||||
|
channelId: first.channel_id,
|
||||||
|
threadId: first.thread_id,
|
||||||
|
label,
|
||||||
|
groups: groupByUser(channelMessages),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageFeed({
|
export function MessageFeed({
|
||||||
messages,
|
messages,
|
||||||
onReanalyze,
|
onReanalyze,
|
||||||
@@ -62,13 +115,13 @@ export function MessageFeed({
|
|||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries[0]?.isIntersecting) onLoadMore();
|
if (entries[0]?.isIntersecting) onLoadMore();
|
||||||
},
|
},
|
||||||
{ rootMargin: "400px" }, // preload before user reaches bottom
|
{ rootMargin: "400px" },
|
||||||
);
|
);
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [onLoadMore, hasMore]);
|
}, [onLoadMore, hasMore]);
|
||||||
|
|
||||||
const groupedMessages = useMemo(() => groupMessages(messages), [messages]);
|
const sections = useMemo(() => computeChannelSections(messages), [messages]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -94,7 +147,18 @@ export function MessageFeed({
|
|||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
>
|
>
|
||||||
{groupedMessages.map((group) => (
|
{sections.map((section) => (
|
||||||
|
<div key={section.key} className="space-y-2">
|
||||||
|
{/* Channel/Thread section header */}
|
||||||
|
<div className="sticky top-0 z-10 -mx-1 rounded-lg bg-muted/80 backdrop-blur-sm px-3 py-1.5 flex items-center gap-1.5">
|
||||||
|
<Hash className="h-3.5 w-3.5 text-primary/60" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{section.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{section.groups.map((group) => (
|
||||||
<motion.div key={group.messages[0].id} variants={cardItem}>
|
<motion.div key={group.messages[0].id} variants={cardItem}>
|
||||||
<MessageCard
|
<MessageCard
|
||||||
messages={group.messages}
|
messages={group.messages}
|
||||||
@@ -102,6 +166,9 @@ export function MessageFeed({
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Infinite-scroll sentinel */}
|
{/* Infinite-scroll sentinel */}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
|
|||||||
Reference in New Issue
Block a user