mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
feat(desktop): add inbox row quick actions
Expose row-level inbox actions for marking unread, opening the source message, and setting reminders without leaving the list context. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -41,6 +41,7 @@ import {
|
||||
countDueReminders,
|
||||
useRemindersQuery,
|
||||
} from "@/features/reminders/hooks";
|
||||
import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider";
|
||||
import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri";
|
||||
import type { HomeFeedResponse } from "@/shared/api/types";
|
||||
import { KIND_REACTION } from "@/shared/constants/kinds";
|
||||
@@ -108,6 +109,7 @@ export function HomeView({
|
||||
);
|
||||
const [isDeletingMessage, setIsDeletingMessage] = React.useState(false);
|
||||
const [isSendingReply, setIsSendingReply] = React.useState(false);
|
||||
const { openReminder, activeReminderEventIds } = useRemindLater();
|
||||
const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState<
|
||||
Record<string, InboxReply[]>
|
||||
>({});
|
||||
@@ -407,11 +409,29 @@ export function HomeView({
|
||||
>
|
||||
{showListPane ? (
|
||||
<InboxListPane
|
||||
activeReminderEventIds={activeReminderEventIds}
|
||||
doneSet={effectiveDoneSet}
|
||||
dueReminderCount={dueReminderCount}
|
||||
filter={filter}
|
||||
items={filteredItems}
|
||||
onFilterChange={setFilter}
|
||||
onMarkUnread={markItemUnread}
|
||||
onOpenDirect={(item) => {
|
||||
const channelId = item.item.channelId;
|
||||
if (!channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenContext(channelId, item.id);
|
||||
}}
|
||||
onRemindLater={(item) => {
|
||||
openReminder({
|
||||
eventId: item.id,
|
||||
channelId: item.item.channelId ?? "",
|
||||
preview: item.preview.slice(0, 100),
|
||||
authorPubkey: item.item.pubkey,
|
||||
});
|
||||
}}
|
||||
onSelect={(itemId) => {
|
||||
handleUserSelectItem(itemId);
|
||||
markItemRead(itemId);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, Mail } from "lucide-react";
|
||||
import { Clock, ExternalLink, Mail, MailOpen, ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -36,11 +37,15 @@ type InboxListPaneProps = {
|
||||
filter: InboxFilter;
|
||||
items: InboxItem[];
|
||||
onFilterChange: (filter: InboxFilter) => void;
|
||||
onMarkUnread: (itemId: string) => void;
|
||||
onOpenDirect: (item: InboxItem) => void;
|
||||
onRemindLater: (item: InboxItem) => void;
|
||||
onSelect: (itemId: string) => void;
|
||||
selectedId: string | null;
|
||||
showRightDivider?: boolean;
|
||||
dueReminderCount: number;
|
||||
reminderPubkey?: string;
|
||||
activeReminderEventIds?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export function InboxListPane({
|
||||
@@ -48,11 +53,15 @@ export function InboxListPane({
|
||||
filter,
|
||||
items,
|
||||
onFilterChange,
|
||||
onMarkUnread,
|
||||
onOpenDirect,
|
||||
onRemindLater,
|
||||
onSelect,
|
||||
selectedId,
|
||||
showRightDivider = false,
|
||||
dueReminderCount,
|
||||
reminderPubkey,
|
||||
activeReminderEventIds,
|
||||
}: InboxListPaneProps) {
|
||||
const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter);
|
||||
const isReminders = filter === "reminders";
|
||||
@@ -61,80 +70,116 @@ export function InboxListPane({
|
||||
const renderItem = (item: InboxItem) => {
|
||||
const isSelected = item.id === selectedId;
|
||||
const isDone = doneSet.has(item.id);
|
||||
const hasActiveReminder = activeReminderEventIds?.has(item.id) ?? false;
|
||||
const hasChannelTarget = Boolean(item.item.channelId);
|
||||
const typeLabel = formatInboxTypeLabel(item);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2.5 border-l px-5 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-l-transparent bg-muted/30"
|
||||
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
|
||||
)}
|
||||
<div
|
||||
className="group/inbox-item relative"
|
||||
data-testid={`home-inbox-item-${item.id}`}
|
||||
onClick={() => onSelect(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="relative">
|
||||
<UserAvatar
|
||||
avatarUrl={item.avatarUrl}
|
||||
className="h-8 w-8"
|
||||
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-baseline gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{item.senderLabel}
|
||||
</p>
|
||||
{item.isActionRequired ? (
|
||||
<span className="inline-flex shrink-0 items-center text-2xs font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
|
||||
Needs action
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 truncate text-2xs text-muted-foreground",
|
||||
isDone ? "font-normal" : "font-semibold",
|
||||
)}
|
||||
>
|
||||
{typeLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs leading-5 text-muted-foreground",
|
||||
isDone ? "font-normal" : "font-semibold",
|
||||
)}
|
||||
>
|
||||
{item.timestampLabel}
|
||||
</span>
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2.5 border-l px-5 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-l-transparent bg-muted/30"
|
||||
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
|
||||
)}
|
||||
onClick={() => onSelect(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="relative">
|
||||
<UserAvatar
|
||||
avatarUrl={item.avatarUrl}
|
||||
className="h-8 w-8"
|
||||
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>
|
||||
|
||||
<Markdown
|
||||
className={cn(
|
||||
"mt-0.5 line-clamp-2 max-w-full text-sm! leading-5! **:inline [&_*]:text-inherit [&_a]:font-medium [&_a]:text-current [&_br]:hidden [&_p]:inline",
|
||||
isDone
|
||||
? "font-normal text-muted-foreground"
|
||||
: "font-semibold text-foreground",
|
||||
)}
|
||||
content={item.preview}
|
||||
interactive={false}
|
||||
mentionNames={item.mentionNames}
|
||||
plainInlineReferences
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{item.senderLabel}
|
||||
</p>
|
||||
{item.isActionRequired ? (
|
||||
<span className="inline-flex shrink-0 items-center text-2xs font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
|
||||
Needs action
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 truncate text-2xs text-muted-foreground",
|
||||
isDone ? "font-normal" : "font-semibold",
|
||||
)}
|
||||
>
|
||||
{typeLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs leading-5 text-muted-foreground transition-opacity group-hover/inbox-item:opacity-0 group-focus-within/inbox-item:opacity-0",
|
||||
isDone ? "font-normal" : "font-semibold",
|
||||
)}
|
||||
>
|
||||
{item.timestampLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Markdown
|
||||
className={cn(
|
||||
"mt-0.5 line-clamp-2 max-w-full text-sm! leading-5! **:inline [&_*]:text-inherit [&_a]:font-medium [&_a]:text-current [&_br]:hidden [&_p]:inline",
|
||||
isDone
|
||||
? "font-normal text-muted-foreground"
|
||||
: "font-semibold text-foreground",
|
||||
)}
|
||||
content={item.preview}
|
||||
interactive={false}
|
||||
mentionNames={item.mentionNames}
|
||||
plainInlineReferences
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="absolute right-4 top-1.5 z-10 flex items-center gap-1 rounded-full border border-border/70 bg-background/95 p-0.5 opacity-0 shadow-xs backdrop-blur-sm transition-opacity group-hover/inbox-item:opacity-100 group-focus-within/inbox-item:opacity-100 supports-[backdrop-filter]:bg-background/85">
|
||||
<InboxRowActionButton
|
||||
label="Mark unread"
|
||||
onClick={() => onMarkUnread(item.id)}
|
||||
>
|
||||
<MailOpen className="h-3.5 w-3.5" />
|
||||
</InboxRowActionButton>
|
||||
<InboxRowActionButton
|
||||
disabled={!hasChannelTarget}
|
||||
label={hasChannelTarget ? "Open in channel" : "No channel link"}
|
||||
onClick={() => onOpenDirect(item)}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</InboxRowActionButton>
|
||||
<InboxRowActionButton
|
||||
active={hasActiveReminder}
|
||||
disabled={!hasChannelTarget}
|
||||
label={
|
||||
hasChannelTarget
|
||||
? hasActiveReminder
|
||||
? "Reminder set"
|
||||
: "Remind me later"
|
||||
: "Cannot remind without a channel"
|
||||
}
|
||||
onClick={() => onRemindLater(item)}
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
</InboxRowActionButton>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -248,3 +293,44 @@ export function InboxListPane({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxRowActionButton({
|
||||
active = false,
|
||||
children,
|
||||
disabled = false,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40",
|
||||
active && "bg-blue-500/10 text-blue-500 hover:text-blue-500",
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user