mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
feat(desktop): show inbox thread context
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
FeedItem,
|
||||
FeedItemCategory,
|
||||
HomeFeedResponse,
|
||||
RelayEvent,
|
||||
} from "@/shared/api/types";
|
||||
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
|
||||
|
||||
@@ -43,6 +44,12 @@ export type InboxReply = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type InboxContextMessage = InboxReply & {
|
||||
depth: number;
|
||||
isSelected: boolean;
|
||||
mentionNames: string[];
|
||||
};
|
||||
|
||||
export type InboxGroup = {
|
||||
label: string;
|
||||
items: InboxItem[];
|
||||
@@ -190,6 +197,18 @@ export function formatInboxFullTimestamp(unixSeconds: number) {
|
||||
return fullTimeFormatter.format(new Date(unixSeconds * 1_000));
|
||||
}
|
||||
|
||||
export function relayEventFromFeedItem(item: FeedItem): RelayEvent {
|
||||
return {
|
||||
content: item.content,
|
||||
created_at: item.createdAt,
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
pubkey: item.pubkey,
|
||||
sig: "",
|
||||
tags: item.tags,
|
||||
};
|
||||
}
|
||||
|
||||
export function groupInboxItems(items: InboxItem[]): InboxGroup[] {
|
||||
const groups = new Map<string, InboxItem[]>();
|
||||
const now = new Date();
|
||||
|
||||
@@ -4,11 +4,13 @@ import { RefreshCcw } from "lucide-react";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import {
|
||||
type InboxFilter,
|
||||
type InboxContextMessage,
|
||||
type InboxReply,
|
||||
buildInboxItems,
|
||||
formatInboxFullTimestamp,
|
||||
} from "@/features/home/lib/inbox";
|
||||
import { useFeedItemState } from "@/features/home/useFeedItemState";
|
||||
import { useInboxThreadContext } from "@/features/home/useInboxThreadContext";
|
||||
import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane";
|
||||
import { InboxListPane } from "@/features/home/ui/InboxListPane";
|
||||
import { useChannelMessagesQuery } from "@/features/messages/hooks";
|
||||
@@ -16,7 +18,8 @@ import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri";
|
||||
import type { HomeFeedResponse } from "@/shared/api/types";
|
||||
import type { HomeFeedResponse, RelayEvent } from "@/shared/api/types";
|
||||
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
@@ -62,6 +65,23 @@ function HomeLoadingState() {
|
||||
);
|
||||
}
|
||||
|
||||
function getContextMessageDepth(
|
||||
event: RelayEvent,
|
||||
eventById: ReadonlyMap<string, RelayEvent>,
|
||||
): number {
|
||||
let depth = 0;
|
||||
let parentId = getThreadReference(event.tags).parentId;
|
||||
const seen = new Set<string>([event.id]);
|
||||
|
||||
while (parentId && eventById.has(parentId) && !seen.has(parentId)) {
|
||||
depth += 1;
|
||||
seen.add(parentId);
|
||||
parentId = getThreadReference(eventById.get(parentId)?.tags ?? []).parentId;
|
||||
}
|
||||
|
||||
return depth;
|
||||
}
|
||||
|
||||
type HomeViewProps = {
|
||||
feed?: HomeFeedResponse;
|
||||
isLoading?: boolean;
|
||||
@@ -104,14 +124,14 @@ export function HomeView({
|
||||
: [],
|
||||
[feed],
|
||||
);
|
||||
const selectedFeedItem =
|
||||
feedItems.find((item) => item.id === selectedItemId) ?? null;
|
||||
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const channels = channelsQuery.data;
|
||||
const selectedChannelIdCandidate = React.useMemo(() => {
|
||||
if (!selectedItemId) return null;
|
||||
const match = feedItems.find((item) => item.id === selectedItemId);
|
||||
return match?.channelId ?? null;
|
||||
}, [feedItems, selectedItemId]);
|
||||
return selectedFeedItem?.channelId ?? null;
|
||||
}, [selectedFeedItem]);
|
||||
const selectedChannel = React.useMemo(() => {
|
||||
if (!selectedChannelIdCandidate || !channels) return null;
|
||||
return (
|
||||
@@ -122,26 +142,20 @@ export function HomeView({
|
||||
|
||||
const channelMessagesQuery = useChannelMessagesQuery(selectedChannel);
|
||||
const channelMessages = channelMessagesQuery.data;
|
||||
const threadEvents = React.useMemo(() => {
|
||||
if (!selectedItemId || !channelMessages) return [];
|
||||
return channelMessages
|
||||
.filter((event) => {
|
||||
if (event.id === selectedItemId) return false;
|
||||
const ref = getThreadReference(event.tags);
|
||||
return ref.parentId === selectedItemId || ref.rootId === selectedItemId;
|
||||
})
|
||||
.sort((a, b) => a.created_at - b.created_at);
|
||||
}, [channelMessages, selectedItemId]);
|
||||
const threadContext = useInboxThreadContext(
|
||||
selectedFeedItem,
|
||||
channelMessages,
|
||||
);
|
||||
|
||||
const feedProfilePubkeys = React.useMemo(
|
||||
() => [
|
||||
...new Set([
|
||||
...feedItems.map((item) => item.pubkey),
|
||||
...threadEvents.map((event) => event.pubkey),
|
||||
...threadContext.events.map((event) => event.pubkey),
|
||||
...(currentPubkey ? [currentPubkey] : []),
|
||||
]),
|
||||
],
|
||||
[currentPubkey, feedItems, threadEvents],
|
||||
[currentPubkey, feedItems, threadContext.events],
|
||||
);
|
||||
const feedProfilesQuery = useUsersBatchQuery(feedProfilePubkeys, {
|
||||
enabled: feedProfilePubkeys.length > 0,
|
||||
@@ -170,32 +184,37 @@ export function HomeView({
|
||||
}, [filter, inboxItems, searchValue]);
|
||||
const selectedItem =
|
||||
filteredItems.find((item) => item.id === selectedItemId) ?? null;
|
||||
const threadReplies = React.useMemo<InboxReply[]>(
|
||||
() =>
|
||||
threadEvents.map((event) => ({
|
||||
id: event.id,
|
||||
authorLabel: resolveUserLabel({
|
||||
pubkey: event.pubkey,
|
||||
currentPubkey,
|
||||
profiles: feedProfiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
}),
|
||||
avatarUrl:
|
||||
feedProfiles?.[event.pubkey.toLowerCase()]?.avatarUrl ?? null,
|
||||
content: event.content,
|
||||
fullTimestampLabel: formatInboxFullTimestamp(event.created_at),
|
||||
})),
|
||||
[currentPubkey, feedProfiles, threadEvents],
|
||||
);
|
||||
const contextMessages = React.useMemo<InboxContextMessage[]>(() => {
|
||||
if (!selectedItem) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eventById = new Map(
|
||||
threadContext.events.map((event) => [event.id, event]),
|
||||
);
|
||||
|
||||
return threadContext.events.map((event) => ({
|
||||
id: event.id,
|
||||
authorLabel: resolveUserLabel({
|
||||
pubkey: event.pubkey,
|
||||
currentPubkey,
|
||||
profiles: feedProfiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
}),
|
||||
avatarUrl: feedProfiles?.[event.pubkey.toLowerCase()]?.avatarUrl ?? null,
|
||||
content: event.content,
|
||||
depth: getContextMessageDepth(event, eventById),
|
||||
fullTimestampLabel: formatInboxFullTimestamp(event.created_at),
|
||||
isSelected: event.id === selectedItem.id,
|
||||
mentionNames: resolveMentionNames(event.tags, feedProfiles) ?? [],
|
||||
}));
|
||||
}, [currentPubkey, feedProfiles, selectedItem, threadContext.events]);
|
||||
const selectedItemReplies = React.useMemo<InboxReply[]>(() => {
|
||||
if (!selectedItem) return [];
|
||||
const localReplies = localRepliesByItemId[selectedItem.id] ?? [];
|
||||
const remoteIds = new Set(threadReplies.map((reply) => reply.id));
|
||||
const pendingLocals = localReplies.filter(
|
||||
(reply) => !remoteIds.has(reply.id),
|
||||
);
|
||||
return [...threadReplies, ...pendingLocals];
|
||||
}, [localRepliesByItemId, selectedItem, threadReplies]);
|
||||
const contextIds = new Set(contextMessages.map((message) => message.id));
|
||||
return localReplies.filter((reply) => !contextIds.has(reply.id));
|
||||
}, [contextMessages, localRepliesByItemId, selectedItem]);
|
||||
React.useEffect(() => {
|
||||
if (filteredItems.length === 0) {
|
||||
setSelectedItemId(null);
|
||||
@@ -300,7 +319,9 @@ export function HomeView({
|
||||
isDone={selectedItem ? doneSet.has(selectedItem.id) : false}
|
||||
isDeletingMessage={isDeletingMessage}
|
||||
isSendingReply={isSendingReply}
|
||||
isThreadContextLoading={threadContext.isLoading}
|
||||
item={selectedItem}
|
||||
messages={contextMessages}
|
||||
replies={selectedItemReplies}
|
||||
onDelete={() => {
|
||||
if (!selectedItem || !canDelete) {
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { InboxItem, InboxReply } from "@/features/home/lib/inbox";
|
||||
import type {
|
||||
InboxContextMessage,
|
||||
InboxItem,
|
||||
InboxReply,
|
||||
} from "@/features/home/lib/inbox";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -37,7 +41,9 @@ type InboxDetailPaneProps = {
|
||||
isDone: boolean;
|
||||
isDeletingMessage?: boolean;
|
||||
isSendingReply?: boolean;
|
||||
isThreadContextLoading?: boolean;
|
||||
item: InboxItem | null;
|
||||
messages?: InboxContextMessage[];
|
||||
replies?: InboxReply[];
|
||||
onDelete: () => void;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
@@ -57,7 +63,9 @@ export function InboxDetailPane({
|
||||
isDone,
|
||||
isDeletingMessage = false,
|
||||
isSendingReply = false,
|
||||
isThreadContextLoading = false,
|
||||
item,
|
||||
messages = [],
|
||||
replies = [],
|
||||
onDelete,
|
||||
onOpenChannel,
|
||||
@@ -96,6 +104,30 @@ export function InboxDetailPane({
|
||||
}
|
||||
|
||||
const channelId = item.item.channelId;
|
||||
const selectedMessage = messages.find((message) => message.isSelected);
|
||||
const pendingReplyMessages: InboxContextMessage[] = replies.map((reply) => ({
|
||||
...reply,
|
||||
depth: (selectedMessage?.depth ?? 0) + 1,
|
||||
isSelected: false,
|
||||
mentionNames: [],
|
||||
}));
|
||||
const displayMessages =
|
||||
messages.length > 0
|
||||
? [...messages, ...pendingReplyMessages]
|
||||
: [
|
||||
{
|
||||
authorLabel: item.senderLabel,
|
||||
avatarUrl: item.avatarUrl,
|
||||
content: item.preview,
|
||||
depth: 0,
|
||||
fullTimestampLabel: item.fullTimestampLabel,
|
||||
id: item.id,
|
||||
isSelected: true,
|
||||
mentionNames: item.mentionNames,
|
||||
},
|
||||
...pendingReplyMessages,
|
||||
];
|
||||
const hasConversationContext = displayMessages.length > 1;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -192,49 +224,71 @@ export function InboxDetailPane({
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-6">
|
||||
<div>
|
||||
<div className="px-6 pb-5">
|
||||
<Markdown
|
||||
className="max-w-none text-left text-[15px] text-foreground"
|
||||
content={item.preview}
|
||||
mentionNames={item.mentionNames}
|
||||
tight
|
||||
/>
|
||||
<div className="px-6 pb-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{hasConversationContext
|
||||
? "Conversation context"
|
||||
: item.categoryLabel}
|
||||
{isThreadContextLoading ? (
|
||||
<span className="ml-2 font-normal normal-case tracking-normal">
|
||||
Loading context...
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{replies.length > 0 ? (
|
||||
<div
|
||||
className="border-t border-border/60 px-6 pt-5 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"
|
||||
data-testid="home-inbox-replies-header"
|
||||
>
|
||||
{replies.length === 1 ? "1 reply" : `${replies.length} replies`}
|
||||
</div>
|
||||
) : null}
|
||||
{replies.map((reply) => (
|
||||
<div
|
||||
className="px-6 py-5"
|
||||
data-testid="home-inbox-reply"
|
||||
key={reply.id}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<UserAvatar
|
||||
avatarUrl={reply.avatarUrl}
|
||||
className="h-8 w-8 rounded-md"
|
||||
displayName={reply.authorLabel}
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{reply.authorLabel}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{reply.fullTimestampLabel}
|
||||
</p>
|
||||
{displayMessages.map((message) => (
|
||||
<div className="px-6 py-3" key={message.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-2xl px-3 py-2",
|
||||
message.isSelected
|
||||
? "bg-primary/10 ring-1 ring-primary/25"
|
||||
: "",
|
||||
)}
|
||||
data-testid={
|
||||
message.isSelected
|
||||
? "home-inbox-selected-message"
|
||||
: "home-inbox-context-message"
|
||||
}
|
||||
style={{
|
||||
marginLeft: `${Math.min(message.depth, 6) * 24}px`,
|
||||
}}
|
||||
>
|
||||
{message.depth > 0 ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-2 top-2 border-l border-border/70"
|
||||
style={{ left: "-12px" }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<UserAvatar
|
||||
avatarUrl={message.avatarUrl}
|
||||
className="h-8 w-8 rounded-md"
|
||||
displayName={message.authorLabel}
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{message.authorLabel}
|
||||
</p>
|
||||
{message.isSelected ? (
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-primary">
|
||||
Inbox item
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{message.fullTimestampLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Markdown
|
||||
className="max-w-none text-left text-[15px] text-foreground"
|
||||
content={message.content}
|
||||
mentionNames={message.mentionNames}
|
||||
tight
|
||||
/>
|
||||
</div>
|
||||
<Markdown
|
||||
className="max-w-none text-left text-[15px] text-foreground"
|
||||
content={reply.content}
|
||||
tight
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Inbox, Search } from "lucide-react";
|
||||
|
||||
import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox";
|
||||
import { groupInboxItems } from "@/features/home/lib/inbox";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
@@ -36,8 +35,6 @@ export function InboxListPane({
|
||||
searchValue,
|
||||
selectedId,
|
||||
}: InboxListPaneProps) {
|
||||
const groups = groupInboxItems(items);
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden border-r border-border/70 bg-background">
|
||||
<div
|
||||
@@ -88,7 +85,7 @@ export function InboxListPane({
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
||||
data-testid="home-inbox-list"
|
||||
>
|
||||
{groups.length === 0 ? (
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-full min-h-64 items-center justify-center px-6 text-center">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
@@ -100,106 +97,93 @@ export function InboxListPane({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<div
|
||||
className="border-b border-border/60 last:border-b-0"
|
||||
key={group.label}
|
||||
>
|
||||
<div className="sticky top-0 z-10 bg-background px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/95">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{items.map((item) => {
|
||||
const isSelected = item.id === selectedId;
|
||||
const isDone = doneSet.has(item.id);
|
||||
|
||||
<div>
|
||||
{group.items.map((item) => {
|
||||
const isSelected = item.id === selectedId;
|
||||
const isDone = doneSet.has(item.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2.5 border-l px-4 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-l-primary bg-muted/30"
|
||||
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
|
||||
)}
|
||||
data-testid={`home-inbox-item-${item.id}`}
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="relative">
|
||||
<UserAvatar
|
||||
avatarUrl={item.avatarUrl}
|
||||
className="h-8 w-8 rounded-md"
|
||||
displayName={item.senderLabel}
|
||||
size="md"
|
||||
/>
|
||||
{!isDone ? (
|
||||
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border-2 border-background bg-primary" />
|
||||
) : null}
|
||||
</div>
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2.5 border-l px-4 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-l-primary bg-muted/30"
|
||||
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
|
||||
)}
|
||||
data-testid={`home-inbox-item-${item.id}`}
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="relative">
|
||||
<UserAvatar
|
||||
avatarUrl={item.avatarUrl}
|
||||
className="h-8 w-8 rounded-md"
|
||||
displayName={item.senderLabel}
|
||||
size="md"
|
||||
/>
|
||||
{!isDone ? (
|
||||
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border-2 border-background bg-primary" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-sm text-foreground",
|
||||
isDone ? "font-normal" : "font-bold",
|
||||
)}
|
||||
>
|
||||
{item.senderLabel}
|
||||
</p>
|
||||
{item.isActionRequired ? (
|
||||
<span className="inline-flex shrink-0 items-center text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
|
||||
Needs action
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"shrink-0 text-xs text-muted-foreground",
|
||||
isDone ? "font-normal" : "font-semibold",
|
||||
"truncate text-sm text-foreground",
|
||||
isDone ? "font-normal" : "font-bold",
|
||||
)}
|
||||
>
|
||||
{item.timestampLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 line-clamp-2 text-sm leading-5",
|
||||
isDone
|
||||
? "font-normal text-muted-foreground"
|
||||
: "font-semibold text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.preview}
|
||||
</p>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{item.channelLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] text-muted-foreground",
|
||||
isDone ? "font-normal" : "font-semibold",
|
||||
)}
|
||||
>
|
||||
#{item.channelLabel}
|
||||
{item.senderLabel}
|
||||
</p>
|
||||
{item.isActionRequired ? (
|
||||
<span className="inline-flex shrink-0 items-center text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
|
||||
Needs action
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs text-muted-foreground",
|
||||
isDone ? "font-normal" : "font-semibold",
|
||||
)}
|
||||
>
|
||||
{item.timestampLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 line-clamp-2 text-sm leading-5",
|
||||
isDone
|
||||
? "font-normal text-muted-foreground"
|
||||
: "font-semibold text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.preview}
|
||||
</p>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{item.channelLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] text-muted-foreground",
|
||||
isDone ? "font-normal" : "font-semibold",
|
||||
)}
|
||||
>
|
||||
#{item.channelLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
179
desktop/src/features/home/useInboxThreadContext.ts
Normal file
179
desktop/src/features/home/useInboxThreadContext.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { relayEventFromFeedItem } from "@/features/home/lib/inbox";
|
||||
import {
|
||||
getChannelIdFromTags,
|
||||
getThreadReference,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { getEventById } from "@/shared/api/tauri";
|
||||
import type { FeedItem, RelayEvent } from "@/shared/api/types";
|
||||
import { HOME_MENTION_EVENT_KINDS } from "@/shared/constants/kinds";
|
||||
|
||||
type InboxThreadContextResult = {
|
||||
events: RelayEvent[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const THREAD_CONTEXT_LIMIT = 100;
|
||||
|
||||
function dedupeEvents(events: RelayEvent[]): RelayEvent[] {
|
||||
const eventsById = new Map<string, RelayEvent>();
|
||||
for (const event of events) {
|
||||
eventsById.set(event.id, event);
|
||||
}
|
||||
return [...eventsById.values()].sort((a, b) => a.created_at - b.created_at);
|
||||
}
|
||||
|
||||
function getThreadRootId(event: RelayEvent): string {
|
||||
const thread = getThreadReference(event.tags);
|
||||
return thread.rootId ?? thread.parentId ?? event.id;
|
||||
}
|
||||
|
||||
function isSameChannel(event: RelayEvent, channelId: string | null): boolean {
|
||||
if (!channelId) {
|
||||
return true;
|
||||
}
|
||||
return getChannelIdFromTags(event.tags) === channelId;
|
||||
}
|
||||
|
||||
export function useInboxThreadContext(
|
||||
item: FeedItem | null,
|
||||
channelMessages: RelayEvent[] | undefined,
|
||||
): InboxThreadContextResult {
|
||||
const [fetchedEvents, setFetchedEvents] = React.useState<RelayEvent[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const selectedEvent = React.useMemo(
|
||||
() => (item ? relayEventFromFeedItem(item) : null),
|
||||
[item],
|
||||
);
|
||||
|
||||
const selectedThreadRootId = selectedEvent
|
||||
? getThreadRootId(selectedEvent)
|
||||
: null;
|
||||
const selectedParentId = selectedEvent
|
||||
? getThreadReference(selectedEvent.tags).parentId
|
||||
: null;
|
||||
const selectedChannelId = item?.channelId ?? null;
|
||||
|
||||
React.useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
if (!selectedEvent || !selectedThreadRootId) {
|
||||
setFetchedEvents([]);
|
||||
setIsLoading(false);
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
async function loadContext() {
|
||||
const targetEvent = selectedEvent;
|
||||
const threadRootId = selectedThreadRootId;
|
||||
if (!targetEvent || !threadRootId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const eventIds = new Set<string>([threadRootId]);
|
||||
if (selectedParentId) {
|
||||
eventIds.add(selectedParentId);
|
||||
}
|
||||
|
||||
const ancestorEvents = await Promise.all(
|
||||
[...eventIds]
|
||||
.filter((eventId) => eventId !== targetEvent.id)
|
||||
.map(async (eventId) => {
|
||||
try {
|
||||
return await getEventById(eventId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const descendantEvents =
|
||||
selectedChannelId && threadRootId
|
||||
? await relayClient
|
||||
.fetchEvents({
|
||||
"#e": [threadRootId],
|
||||
"#h": [selectedChannelId],
|
||||
kinds: [...HOME_MENTION_EVENT_KINDS],
|
||||
limit: THREAD_CONTEXT_LIMIT,
|
||||
})
|
||||
.catch(() => [])
|
||||
: [];
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchedEvents(
|
||||
dedupeEvents(
|
||||
[...ancestorEvents, ...descendantEvents].filter(
|
||||
(event): event is RelayEvent =>
|
||||
event !== null && isSameChannel(event, selectedChannelId),
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadContext();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [
|
||||
selectedChannelId,
|
||||
selectedEvent,
|
||||
selectedParentId,
|
||||
selectedThreadRootId,
|
||||
]);
|
||||
|
||||
const events = React.useMemo(() => {
|
||||
if (!selectedEvent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const localContext = (channelMessages ?? []).filter((event) => {
|
||||
if (!isSameChannel(event, selectedChannelId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.id === selectedEvent.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const thread = getThreadReference(event.tags);
|
||||
return (
|
||||
event.id === selectedThreadRootId ||
|
||||
event.id === selectedParentId ||
|
||||
thread.rootId === selectedThreadRootId ||
|
||||
thread.parentId === selectedThreadRootId ||
|
||||
thread.parentId === selectedEvent.id
|
||||
);
|
||||
});
|
||||
|
||||
return dedupeEvents([selectedEvent, ...fetchedEvents, ...localContext]);
|
||||
}, [
|
||||
channelMessages,
|
||||
fetchedEvents,
|
||||
selectedChannelId,
|
||||
selectedEvent,
|
||||
selectedParentId,
|
||||
selectedThreadRootId,
|
||||
]);
|
||||
|
||||
return {
|
||||
events,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user