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:
Thomas Petersen
2026-06-18 12:34:27 -04:00
parent b8abaf2f68
commit 586ccdda69
2 changed files with 173 additions and 67 deletions

View File

@@ -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);

View File

@@ -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>
);
}