mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Update inbox conversation experience (#608)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { HomeScreen } from "@/features/home/ui/HomeScreen";
|
||||
@@ -9,6 +10,7 @@ export const Route = createFileRoute("/")({
|
||||
});
|
||||
|
||||
function HomeRouteComponent() {
|
||||
const { goChannel } = useAppNavigation();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const channels = channelsQuery.data ?? [];
|
||||
@@ -18,6 +20,9 @@ function HomeRouteComponent() {
|
||||
<HomeScreen
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onOpenContext={(channelId, messageId) => {
|
||||
void goChannel(channelId, { messageId });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export function useHomeFeedQuery() {
|
||||
queryKey: ["home-feed"],
|
||||
queryFn: () =>
|
||||
getHomeFeed({
|
||||
limit: 12,
|
||||
limit: 50,
|
||||
types: "mentions,needs_action,activity,agent_activity",
|
||||
}),
|
||||
staleTime: 15_000,
|
||||
|
||||
@@ -5,11 +5,13 @@ import { HomeView } from "@/features/home/ui/HomeView";
|
||||
type HomeScreenProps = {
|
||||
availableChannelIds: ReadonlySet<string>;
|
||||
currentPubkey?: string;
|
||||
onOpenContext: (channelId: string, messageId: string) => void;
|
||||
};
|
||||
|
||||
export function HomeScreen({
|
||||
availableChannelIds,
|
||||
currentPubkey,
|
||||
onOpenContext,
|
||||
}: HomeScreenProps) {
|
||||
const homeFeedQuery = useHomeFeedQuery();
|
||||
|
||||
@@ -33,6 +35,7 @@ export function HomeScreen({
|
||||
}
|
||||
feed={homeFeedQuery.data}
|
||||
isLoading={homeFeedQuery.isLoading}
|
||||
onOpenContext={onOpenContext}
|
||||
onRefresh={() => {
|
||||
void homeFeedQuery.refetch();
|
||||
}}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/features/home/lib/inbox";
|
||||
import { useFeedItemState } from "@/features/home/useFeedItemState";
|
||||
import { useInboxThreadContext } from "@/features/home/useInboxThreadContext";
|
||||
import { useResizableInboxListWidth } from "@/features/home/useResizableInboxListWidth";
|
||||
import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane";
|
||||
import { InboxListPane } from "@/features/home/ui/InboxListPane";
|
||||
import {
|
||||
@@ -104,6 +105,7 @@ type HomeViewProps = {
|
||||
errorMessage?: string;
|
||||
currentPubkey?: string;
|
||||
availableChannelIds: ReadonlySet<string>;
|
||||
onOpenContext: (channelId: string, messageId: string) => void;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
@@ -113,6 +115,7 @@ export function HomeView({
|
||||
errorMessage,
|
||||
currentPubkey,
|
||||
availableChannelIds,
|
||||
onOpenContext,
|
||||
onRefresh,
|
||||
}: HomeViewProps) {
|
||||
const [filter, setFilter] = React.useState<InboxFilter>("all");
|
||||
@@ -124,6 +127,12 @@ export function HomeView({
|
||||
const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState<
|
||||
Record<string, InboxReply[]>
|
||||
>({});
|
||||
const {
|
||||
canResetInboxListWidth,
|
||||
handleInboxListResizeStart,
|
||||
handleInboxListWidthReset,
|
||||
inboxListWidthPx,
|
||||
} = useResizableInboxListWidth();
|
||||
const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey);
|
||||
const feedItems = React.useMemo(
|
||||
() =>
|
||||
@@ -303,10 +312,12 @@ export function HomeView({
|
||||
);
|
||||
}
|
||||
|
||||
const canReply =
|
||||
const canReact =
|
||||
selectedItem !== null &&
|
||||
selectedItem.item.channelId !== null &&
|
||||
availableChannelIds.has(selectedItem.item.channelId) &&
|
||||
availableChannelIds.has(selectedItem.item.channelId);
|
||||
const canReply =
|
||||
canReact &&
|
||||
selectedItem.item.kind !== 45001 &&
|
||||
selectedItem.item.kind !== 45003;
|
||||
const disabledReplyReason =
|
||||
@@ -323,10 +334,15 @@ export function HomeView({
|
||||
selectedItem.item.pubkey.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className="grid h-full min-h-0 w-full lg:grid-cols-[320px_minmax(0,1fr)]"
|
||||
className="relative grid min-h-0 flex-1 w-full lg:grid-cols-[var(--home-inbox-list-width)_minmax(0,1fr)]"
|
||||
data-testid="home-inbox"
|
||||
style={
|
||||
{
|
||||
"--home-inbox-list-width": `${inboxListWidthPx}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<InboxListPane
|
||||
doneSet={doneSet}
|
||||
@@ -340,6 +356,25 @@ export function HomeView({
|
||||
selectedId={selectedItemId}
|
||||
/>
|
||||
|
||||
<button
|
||||
aria-label="Resize inbox list"
|
||||
className="group absolute inset-y-0 z-20 hidden w-3 -translate-x-1/2 cursor-col-resize lg:block"
|
||||
data-testid="home-inbox-list-resize-handle"
|
||||
onDoubleClick={
|
||||
canResetInboxListWidth ? handleInboxListWidthReset : undefined
|
||||
}
|
||||
onPointerDown={handleInboxListResizeStart}
|
||||
style={{ left: `${inboxListWidthPx}px` }}
|
||||
title={
|
||||
canResetInboxListWidth
|
||||
? "Drag to resize. Double-click to reset width."
|
||||
: "Drag to resize."
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80 group-focus-visible:bg-border/80" />
|
||||
</button>
|
||||
|
||||
<InboxDetailPane
|
||||
canDelete={canDelete}
|
||||
canOpenChannel={Boolean(
|
||||
@@ -355,6 +390,7 @@ export function HomeView({
|
||||
item={selectedItem}
|
||||
messages={contextMessages}
|
||||
replies={selectedItemReplies}
|
||||
contextChannelName={selectedChannel?.name ?? null}
|
||||
onDelete={() => {
|
||||
if (!selectedItem || !canDelete) {
|
||||
return;
|
||||
@@ -369,6 +405,7 @@ export function HomeView({
|
||||
setIsDeletingMessage(false);
|
||||
});
|
||||
}}
|
||||
onOpenContext={onOpenContext}
|
||||
onSendReply={async ({
|
||||
content,
|
||||
mediaTags,
|
||||
@@ -420,13 +457,8 @@ export function HomeView({
|
||||
setIsSendingReply(false);
|
||||
}
|
||||
}}
|
||||
onToggleDone={() => {
|
||||
if (selectedItem) {
|
||||
handleToggleDone(selectedItem.id);
|
||||
}
|
||||
}}
|
||||
onToggleReaction={
|
||||
canReply
|
||||
canReact
|
||||
? async (message, emoji, remove) => {
|
||||
await toggleReactionMutation.mutateAsync({
|
||||
emoji,
|
||||
@@ -438,6 +470,11 @@ export function HomeView({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onToggleDone={() => {
|
||||
if (selectedItem) {
|
||||
handleToggleDone(selectedItem.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,16 +12,18 @@ import type {
|
||||
InboxItem,
|
||||
InboxReply,
|
||||
} from "@/features/home/lib/inbox";
|
||||
import {
|
||||
type InboxDisplayMessage,
|
||||
InboxMessageRow,
|
||||
} from "@/features/home/ui/InboxMessageRow";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -30,7 +32,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
type InboxDetailPaneProps = {
|
||||
canDelete: boolean;
|
||||
@@ -44,38 +45,23 @@ type InboxDetailPaneProps = {
|
||||
item: InboxItem | null;
|
||||
messages?: InboxContextMessage[];
|
||||
replies?: InboxReply[];
|
||||
contextChannelName?: string | null;
|
||||
onDelete: () => void;
|
||||
onOpenContext?: (channelId: string, messageId: string) => void;
|
||||
onSendReply: (input: {
|
||||
content: string;
|
||||
mediaTags?: string[][];
|
||||
mentionPubkeys: string[];
|
||||
parentEventId: string;
|
||||
}) => Promise<void>;
|
||||
onToggleDone: () => void;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
onToggleDone: () => void;
|
||||
};
|
||||
|
||||
type InboxDisplayMessage = InboxContextMessage & {
|
||||
depth: number;
|
||||
};
|
||||
|
||||
function toActionBarMessage(message: InboxDisplayMessage): TimelineMessage {
|
||||
return {
|
||||
id: message.id,
|
||||
author: message.authorLabel,
|
||||
avatarUrl: message.avatarUrl,
|
||||
body: message.content,
|
||||
createdAt: 0,
|
||||
depth: message.depth,
|
||||
reactions: message.reactions ?? [],
|
||||
time: message.fullTimestampLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export function InboxDetailPane({
|
||||
canDelete,
|
||||
canOpenChannel,
|
||||
@@ -88,16 +74,28 @@ export function InboxDetailPane({
|
||||
item,
|
||||
messages = [],
|
||||
replies = [],
|
||||
contextChannelName = null,
|
||||
onDelete,
|
||||
onOpenContext,
|
||||
onSendReply,
|
||||
onToggleDone,
|
||||
onToggleReaction,
|
||||
onToggleDone,
|
||||
}: InboxDetailPaneProps) {
|
||||
const detailPaneRef = React.useRef<HTMLElement | null>(null);
|
||||
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
|
||||
const [isFocusHighlightVisible, setIsFocusHighlightVisible] =
|
||||
React.useState(true);
|
||||
const selectedItemId = item?.id ?? null;
|
||||
const selectedMessageScrollKey = React.useMemo(() => {
|
||||
if (!selectedItemId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedMessageIndex = messages.findIndex(
|
||||
(message) => message.isSelected,
|
||||
);
|
||||
return `${selectedItemId}:${selectedMessageIndex}:${messages.length}`;
|
||||
}, [messages, selectedItemId]);
|
||||
|
||||
const focusComposer = React.useCallback(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
@@ -126,6 +124,20 @@ export function InboxDetailPane({
|
||||
};
|
||||
}, [selectedItemId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedMessageScrollKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
detailPaneRef.current
|
||||
?.querySelector<HTMLElement>(
|
||||
'[data-testid="home-inbox-selected-message"]',
|
||||
)
|
||||
?.scrollIntoView({ block: "center" });
|
||||
});
|
||||
}, [selectedMessageScrollKey]);
|
||||
|
||||
if (!item) {
|
||||
return (
|
||||
<section
|
||||
@@ -179,6 +191,11 @@ export function InboxDetailPane({
|
||||
id: replyTarget.id,
|
||||
}
|
||||
: null;
|
||||
const channelContextName = contextChannelName ?? item.channelLabel;
|
||||
const contextLabel = channelContextName
|
||||
? `#${channelContextName}`
|
||||
: item.categoryLabel;
|
||||
const contextChannelId = item.item.channelId;
|
||||
|
||||
const handleSelectReplyTarget = (message: InboxDisplayMessage) => {
|
||||
setReplyTargetId((currentReplyTargetId) =>
|
||||
@@ -193,75 +210,41 @@ export function InboxDetailPane({
|
||||
data-testid="home-inbox-detail"
|
||||
ref={detailPaneRef}
|
||||
>
|
||||
{!canOpenChannel ? (
|
||||
<div className="px-6 pb-4 pt-14">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<UserAvatar
|
||||
avatarUrl={item.avatarUrl}
|
||||
className="h-8 w-8 rounded-xl"
|
||||
displayName={item.senderLabel}
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<p className="truncate text-base font-semibold">
|
||||
{item.senderLabel}
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center text-[10px] font-semibold uppercase tracking-[0.14em]",
|
||||
item.isActionRequired
|
||||
? "text-amber-600 dark:text-amber-300"
|
||||
: "text-primary",
|
||||
)}
|
||||
>
|
||||
{item.categoryLabel}
|
||||
</span>
|
||||
{item.channelLabel ? (
|
||||
<span className="inline-flex items-center text-[11px] font-medium text-muted-foreground">
|
||||
#{item.channelLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{item.fullTimestampLabel}</span>
|
||||
<span>Inbox only</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-4">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<HeaderIconAction
|
||||
label={isDone ? "Mark unread" : "Mark done"}
|
||||
onClick={onToggleDone}
|
||||
icon={
|
||||
isDone ? (
|
||||
<MailOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{canDelete ? (
|
||||
<HeaderMoreMenu
|
||||
isDeletingMessage={isDeletingMessage}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div className="absolute inset-x-0 top-0 z-40 flex min-h-[44px] items-center justify-between gap-3 bg-background/70 py-[6px] pl-6 pr-3 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55">
|
||||
<div className="min-w-0">
|
||||
{canOpenChannel && contextChannelId && onOpenContext ? (
|
||||
<button
|
||||
className="truncate text-left text-sm font-semibold leading-5 tracking-tight text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
onClick={() => onOpenContext(contextChannelId, item.id)}
|
||||
title={item.fullTimestampLabel}
|
||||
type="button"
|
||||
>
|
||||
{contextLabel}
|
||||
</button>
|
||||
) : (
|
||||
<h2
|
||||
className="truncate text-sm font-semibold leading-5 tracking-tight text-foreground"
|
||||
title={item.fullTimestampLabel}
|
||||
>
|
||||
{contextLabel}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<HeaderMoreMenu
|
||||
canDelete={canDelete}
|
||||
isDeletingMessage={isDeletingMessage}
|
||||
isDone={isDone}
|
||||
onDelete={onDelete}
|
||||
onToggleDone={onToggleDone}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 overflow-y-auto overscroll-contain pb-32 pt-14">
|
||||
<div>
|
||||
{isThreadContextLoading ? (
|
||||
@@ -274,88 +257,14 @@ export function InboxDetailPane({
|
||||
{index === 1 ? (
|
||||
<div className="mx-6 my-3 border-t border-border/60" />
|
||||
) : null}
|
||||
<div className="px-6 py-2">
|
||||
<article
|
||||
className={cn(
|
||||
"group/message relative flex items-start gap-2.5 px-2 py-1 transition-colors duration-1000",
|
||||
message.isSelected
|
||||
? cn(
|
||||
isFocusHighlightVisible
|
||||
? "bg-primary/[0.07]"
|
||||
: "bg-transparent",
|
||||
)
|
||||
: "hover:bg-muted/20",
|
||||
)}
|
||||
data-testid={
|
||||
message.isSelected
|
||||
? "home-inbox-selected-message"
|
||||
: "home-inbox-context-message"
|
||||
}
|
||||
>
|
||||
{canReply || onToggleReaction ? (
|
||||
<div className="absolute right-2 top-1 z-10">
|
||||
<MessageActionBar
|
||||
activeReplyTargetId={replyTargetId}
|
||||
message={toActionBarMessage(message)}
|
||||
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>
|
||||
) : null}
|
||||
<UserAvatar
|
||||
avatarUrl={message.avatarUrl}
|
||||
className="h-8 w-8 shrink-0 rounded-xl"
|
||||
displayName={message.authorLabel}
|
||||
size="md"
|
||||
/>
|
||||
<div className="-mt-1 min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
|
||||
<p className="truncate text-sm font-semibold leading-none tracking-tight text-foreground">
|
||||
{message.authorLabel}
|
||||
</p>
|
||||
<p className="shrink-0 text-xs font-normal leading-none tabular-nums text-muted-foreground/55">
|
||||
{message.fullTimestampLabel}
|
||||
</p>
|
||||
{message.isSelected ? (
|
||||
<span className="text-[10px] font-semibold uppercase leading-none tracking-[0.14em] text-muted-foreground/70">
|
||||
Inbox item
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<Markdown
|
||||
className="max-w-full text-left text-sm text-foreground"
|
||||
content={message.content}
|
||||
mentionNames={message.mentionNames}
|
||||
tight
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<InboxMessageRow
|
||||
activeReplyTargetId={replyTargetId}
|
||||
canReply={canReply}
|
||||
isFocusHighlightVisible={isFocusHighlightVisible}
|
||||
message={message}
|
||||
onSelectReplyTarget={handleSelectReplyTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
@@ -396,42 +305,18 @@ export function InboxDetailPane({
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderIconAction({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="h-8 w-8 rounded-full p-0 text-muted-foreground"
|
||||
onClick={onClick}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderMoreMenu({
|
||||
canDelete,
|
||||
isDeletingMessage,
|
||||
isDone,
|
||||
onDelete,
|
||||
onToggleDone,
|
||||
}: {
|
||||
canDelete: boolean;
|
||||
isDeletingMessage: boolean;
|
||||
isDone: boolean;
|
||||
onDelete: () => void;
|
||||
onToggleDone: () => void;
|
||||
}) {
|
||||
const trigger = (
|
||||
<Button
|
||||
@@ -454,14 +339,25 @@ function HeaderMoreMenu({
|
||||
<TooltipContent>More actions</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isDeletingMessage}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete message
|
||||
<DropdownMenuItem onClick={onToggleDone}>
|
||||
{isDone ? (
|
||||
<MailOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
)}
|
||||
{isDone ? "Unmark as read" : "Mark as read"}
|
||||
</DropdownMenuItem>
|
||||
{canDelete ? <DropdownMenuSeparator /> : null}
|
||||
{canDelete ? (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isDeletingMessage}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete message
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -29,8 +29,8 @@ export function InboxListPane({
|
||||
selectedId,
|
||||
}: InboxListPaneProps) {
|
||||
return (
|
||||
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden border-r border-border/70 bg-background/60">
|
||||
<div className="px-4 pb-3 pt-14">
|
||||
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60">
|
||||
<div className="px-5 pb-3 pt-14">
|
||||
<div className="flex flex-nowrap gap-1">
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<Button
|
||||
@@ -72,7 +72,7 @@ export function InboxListPane({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2.5 border-l px-4 py-2 text-left transition-colors",
|
||||
"flex w-full items-start gap-2.5 border-l px-5 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-l-primary bg-muted/30"
|
||||
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { InboxContextMessage } from "@/features/home/lib/inbox";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
|
||||
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
export type InboxDisplayMessage = InboxContextMessage & {
|
||||
depth: number;
|
||||
};
|
||||
|
||||
function toTimelineMessage(message: InboxDisplayMessage): TimelineMessage {
|
||||
return {
|
||||
id: message.id,
|
||||
author: message.authorLabel,
|
||||
avatarUrl: message.avatarUrl,
|
||||
body: message.content,
|
||||
createdAt: 0,
|
||||
depth: message.depth,
|
||||
reactions: message.reactions ?? [],
|
||||
time: message.fullTimestampLabel,
|
||||
};
|
||||
}
|
||||
|
||||
type InboxMessageRowProps = {
|
||||
activeReplyTargetId: string | null;
|
||||
canReply: boolean;
|
||||
isFocusHighlightVisible: boolean;
|
||||
message: InboxDisplayMessage;
|
||||
onSelectReplyTarget: (message: InboxDisplayMessage) => void;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function InboxMessageRow({
|
||||
activeReplyTargetId,
|
||||
canReply,
|
||||
isFocusHighlightVisible,
|
||||
message,
|
||||
onSelectReplyTarget,
|
||||
onToggleReaction,
|
||||
}: InboxMessageRowProps) {
|
||||
const timelineMessage = React.useMemo(
|
||||
() => toTimelineMessage(message),
|
||||
[message],
|
||||
);
|
||||
const {
|
||||
reactions,
|
||||
canToggle: canToggleReactions,
|
||||
pending: reactionPending,
|
||||
errorMessage: reactionErrorMessage,
|
||||
select: handleReactionSelect,
|
||||
} = useReactionHandler(timelineMessage, onToggleReaction);
|
||||
|
||||
return (
|
||||
<div className="px-6 py-2">
|
||||
<article
|
||||
className={cn(
|
||||
"group/message relative flex items-start gap-2.5 px-2 py-1",
|
||||
!message.isSelected && "hover:bg-muted/20",
|
||||
)}
|
||||
data-testid={
|
||||
message.isSelected
|
||||
? "home-inbox-selected-message"
|
||||
: "home-inbox-context-message"
|
||||
}
|
||||
>
|
||||
{message.isSelected ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute -inset-x-2 -inset-y-1 rounded-xl transition-opacity duration-1000",
|
||||
isFocusHighlightVisible
|
||||
? "bg-primary/[0.07] opacity-100"
|
||||
: "bg-primary/[0.07] opacity-0",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{canReply || canToggleReactions ? (
|
||||
<div className="absolute right-2 top-1 z-10">
|
||||
<MessageActionBar
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
message={timelineMessage}
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onReply={
|
||||
canReply ? () => onSelectReplyTarget(message) : undefined
|
||||
}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactionPending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<UserAvatar
|
||||
avatarUrl={message.avatarUrl}
|
||||
className="!h-9 !w-9 shrink-0"
|
||||
displayName={message.authorLabel}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<div className="-mt-1 min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
|
||||
<p className="truncate text-sm font-semibold leading-none tracking-tight text-foreground">
|
||||
{message.authorLabel}
|
||||
</p>
|
||||
<p className="shrink-0 text-xs font-normal leading-none tabular-nums text-muted-foreground/55">
|
||||
{message.fullTimestampLabel}
|
||||
</p>
|
||||
{message.isSelected ? (
|
||||
<span className="text-[10px] font-semibold uppercase leading-none tracking-[0.14em] text-muted-foreground/70">
|
||||
Inbox item
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<Markdown
|
||||
className="max-w-full text-left text-sm text-foreground"
|
||||
content={message.content}
|
||||
mentionNames={message.mentionNames}
|
||||
tight
|
||||
/>
|
||||
<MessageReactions
|
||||
canToggle={canToggleReactions}
|
||||
messageId={message.id}
|
||||
onSelect={(emoji) => {
|
||||
void handleReactionSelect(emoji);
|
||||
}}
|
||||
pending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-1.5 text-xs text-destructive">
|
||||
{reactionErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as React from "react";
|
||||
|
||||
const INBOX_LIST_DEFAULT_WIDTH_PX = 320;
|
||||
const INBOX_LIST_MIN_WIDTH_PX = 260;
|
||||
const INBOX_LIST_MAX_WIDTH_PX = 520;
|
||||
const INBOX_LIST_WIDTH_SESSION_KEY = "sprout.desktop.home-inbox-list-width";
|
||||
|
||||
function clampInboxListWidth(width: number): number {
|
||||
return Math.max(
|
||||
INBOX_LIST_MIN_WIDTH_PX,
|
||||
Math.min(INBOX_LIST_MAX_WIDTH_PX, width),
|
||||
);
|
||||
}
|
||||
|
||||
function getInitialInboxListWidth(): number {
|
||||
if (typeof window === "undefined") {
|
||||
return INBOX_LIST_DEFAULT_WIDTH_PX;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(INBOX_LIST_WIDTH_SESSION_KEY);
|
||||
if (!raw) {
|
||||
return INBOX_LIST_DEFAULT_WIDTH_PX;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return INBOX_LIST_DEFAULT_WIDTH_PX;
|
||||
}
|
||||
|
||||
return clampInboxListWidth(parsed);
|
||||
} catch {
|
||||
return INBOX_LIST_DEFAULT_WIDTH_PX;
|
||||
}
|
||||
}
|
||||
|
||||
export function useResizableInboxListWidth() {
|
||||
const [inboxListWidthPx, setInboxListWidthPx] = React.useState<number>(() =>
|
||||
getInitialInboxListWidth(),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
INBOX_LIST_WIDTH_SESSION_KEY,
|
||||
String(inboxListWidthPx),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage failures and keep the chosen width in memory.
|
||||
}
|
||||
}, [inboxListWidthPx]);
|
||||
|
||||
const handleInboxListResizeStart = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = inboxListWidthPx;
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const previousUserSelect = document.body.style.userSelect;
|
||||
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
setInboxListWidthPx(clampInboxListWidth(startWidth + deltaX));
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
document.body.style.cursor = previousCursor;
|
||||
document.body.style.userSelect = previousUserSelect;
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||
},
|
||||
[inboxListWidthPx],
|
||||
);
|
||||
|
||||
const handleInboxListWidthReset = React.useCallback(() => {
|
||||
setInboxListWidthPx(INBOX_LIST_DEFAULT_WIDTH_PX);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
canResetInboxListWidth: inboxListWidthPx !== INBOX_LIST_DEFAULT_WIDTH_PX,
|
||||
handleInboxListResizeStart,
|
||||
handleInboxListWidthReset,
|
||||
inboxListWidthPx,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user