mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
fix(desktop): add inbox reactions and clean header
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user