fix(desktop): add inbox reactions and clean header

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-05-13 17:32:38 -04:00
parent b52964dcb3
commit 50ccef6980
4 changed files with 114 additions and 24 deletions

View File

@@ -3,6 +3,7 @@ import {
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { getThreadReference } from "@/features/messages/lib/threading";
import type { TimelineReaction } from "@/features/messages/types";
import type {
FeedItem,
FeedItemCategory,
@@ -43,6 +44,7 @@ export type InboxReply = {
fullTimestampLabel: string;
id: string;
parentId?: string | null;
reactions?: TimelineReaction[];
rootId?: string | null;
};

View File

@@ -13,12 +13,17 @@ 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";
import {
useChannelMessagesQuery,
useToggleReactionMutation,
} from "@/features/messages/hooks";
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
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, RelayEvent } from "@/shared/api/types";
import { KIND_REACTION } from "@/shared/constants/kinds";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
@@ -82,6 +87,17 @@ function getContextMessageDepth(
return depth;
}
function getReactionTargetId(tags: string[][]) {
for (let index = tags.length - 1; index >= 0; index -= 1) {
const tag = tags[index];
if (tag?.[0] === "e" && typeof tag[1] === "string") {
return tag[1];
}
}
return null;
}
type HomeViewProps = {
feed?: HomeFeedResponse;
isLoading?: boolean;
@@ -140,6 +156,7 @@ export function HomeView({
}, [channels, selectedChannelIdCandidate]);
const channelMessagesQuery = useChannelMessagesQuery(selectedChannel);
const toggleReactionMutation = useToggleReactionMutation();
const channelMessages = channelMessagesQuery.data;
const threadContext = useInboxThreadContext(
selectedFeedItem,
@@ -151,10 +168,13 @@ export function HomeView({
...new Set([
...feedItems.map((item) => item.pubkey),
...threadContext.events.map((event) => event.pubkey),
...(channelMessages ?? [])
.filter((event) => event.kind === KIND_REACTION)
.map((event) => event.pubkey),
...(currentPubkey ? [currentPubkey] : []),
]),
],
[currentPubkey, feedItems, threadContext.events],
[channelMessages, currentPubkey, feedItems, threadContext.events],
);
const feedProfilesQuery = useUsersBatchQuery(feedProfilePubkeys, {
enabled: feedProfilePubkeys.length > 0,
@@ -182,23 +202,49 @@ export function HomeView({
const eventById = new Map(
threadContext.events.map((event) => [event.id, event]),
);
const contextEventIds = new Set(eventById.keys());
const reactionEvents = (channelMessages ?? []).filter((event) => {
if (event.kind !== KIND_REACTION) {
return false;
}
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 targetId = getReactionTargetId(event.tags);
return Boolean(targetId && contextEventIds.has(targetId));
});
const currentUserAvatarUrl = currentPubkey
? (feedProfiles?.[currentPubkey.toLowerCase()]?.avatarUrl ?? null)
: null;
const timelineMessages = formatTimelineMessages(
[...threadContext.events, ...reactionEvents],
selectedChannel,
currentPubkey,
currentUserAvatarUrl,
feedProfiles,
);
return timelineMessages.map((message) => {
const event = eventById.get(message.id);
return {
id: message.id,
authorLabel: message.author,
avatarUrl: message.avatarUrl ?? null,
content: message.body,
depth: event ? getContextMessageDepth(event, eventById) : message.depth,
fullTimestampLabel: formatInboxFullTimestamp(message.createdAt),
isSelected: message.id === selectedItem.id,
mentionNames:
resolveMentionNames(message.tags ?? [], feedProfiles) ?? [],
reactions: message.reactions,
};
});
}, [
channelMessages,
currentPubkey,
feedProfiles,
selectedChannel,
selectedItem,
threadContext.events,
]);
const selectedItemReplies = React.useMemo<InboxReply[]>(() => {
if (!selectedItem) return [];
const localReplies = localRepliesByItemId[selectedItem.id] ?? [];
@@ -382,6 +428,19 @@ export function HomeView({
handleToggleDone(selectedItem.id);
}
}}
onToggleReaction={
canReply
? async (message, emoji, remove) => {
await toggleReactionMutation.mutateAsync({
emoji,
eventId: message.id,
remove,
});
await channelMessagesQuery.refetch();
onRefresh();
}
: undefined
}
/>
</div>
</div>

View File

@@ -56,6 +56,11 @@ type InboxDetailPaneProps = {
parentEventId: string;
}) => Promise<void>;
onToggleDone: () => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
};
type InboxDisplayMessage = InboxContextMessage & {
@@ -70,7 +75,7 @@ function toActionBarMessage(message: InboxDisplayMessage): TimelineMessage {
body: message.content,
createdAt: 0,
depth: message.depth,
reactions: [],
reactions: message.reactions ?? [],
time: message.fullTimestampLabel,
};
}
@@ -91,6 +96,7 @@ export function InboxDetailPane({
onOpenChannel,
onSendReply,
onToggleDone,
onToggleReaction,
}: InboxDetailPaneProps) {
const detailPaneRef = React.useRef<HTMLElement | null>(null);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
@@ -180,7 +186,7 @@ export function InboxDetailPane({
data-testid="home-inbox-detail"
ref={detailPaneRef}
>
<div className="border-b border-border/70 px-6 py-4">
<div className="px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<UserAvatar
@@ -330,14 +336,37 @@ export function InboxDetailPane({
<p className="ml-auto text-xs text-muted-foreground">
{message.fullTimestampLabel}
</p>
{canReply ? (
{canReply || onToggleReaction ? (
<div className="relative ml-1">
<div className="absolute right-0 top-1/2 -translate-y-1/2">
<MessageActionBar
activeReplyTargetId={replyTargetId}
message={toActionBarMessage(message)}
onReply={() => handleSelectReplyTarget(message)}
reactions={[]}
onReactionSelect={
onToggleReaction
? (emoji) => {
const actionBarMessage =
toActionBarMessage(message);
const remove =
actionBarMessage.reactions?.some(
(reaction) =>
reaction.emoji === emoji &&
reaction.reactedByCurrentUser,
) ?? false;
return onToggleReaction(
actionBarMessage,
emoji,
remove,
);
}
: undefined
}
onReply={
canReply
? () => handleSelectReplyTarget(message)
: undefined
}
reactions={message.reactions ?? []}
/>
</div>
</div>

View File

@@ -30,7 +30,7 @@ export function InboxListPane({
}: InboxListPaneProps) {
return (
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden border-r border-border/70 bg-background">
<div className="border-b border-border/70 px-4 pb-3 pt-3">
<div className="px-4 pb-3 pt-3">
<div className="flex flex-wrap gap-1.5">
{FILTER_OPTIONS.map((option) => (
<Button