Add mail-client keyboard shortcuts to inbox mine tab
j/k navigate up/down, a to archive, U to mark unread, r to mark read, Enter to open. Includes server-side DELETE /issues/:id/read endpoint for mark-unread support on issues. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
182b459235
commit
2ec4ba629e
5 changed files with 221 additions and 16 deletions
|
|
@ -713,6 +713,38 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
res.json(readState);
|
res.json(readState);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.delete("/issues/:id/read", async (req, res) => {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const issue = await svc.getById(id);
|
||||||
|
if (!issue) {
|
||||||
|
res.status(404).json({ error: "Issue not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, issue.companyId);
|
||||||
|
if (req.actor.type !== "board") {
|
||||||
|
res.status(403).json({ error: "Board authentication required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!req.actor.userId) {
|
||||||
|
res.status(403).json({ error: "Board user context required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const removed = await svc.markUnread(issue.companyId, issue.id, req.actor.userId);
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "issue.read_unmarked",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: { userId: req.actor.userId },
|
||||||
|
});
|
||||||
|
res.json({ id: issue.id, removed });
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/issues/:id/inbox-archive", async (req, res) => {
|
router.post("/issues/:id/inbox-archive", async (req, res) => {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const issue = await svc.getById(id);
|
const issue = await svc.getById(id);
|
||||||
|
|
|
||||||
|
|
@ -791,6 +791,20 @@ export function issueService(db: Db) {
|
||||||
return row;
|
return row;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
markUnread: async (companyId: string, issueId: string, userId: string) => {
|
||||||
|
const deleted = await db
|
||||||
|
.delete(issueReadStates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(issueReadStates.companyId, companyId),
|
||||||
|
eq(issueReadStates.issueId, issueId),
|
||||||
|
eq(issueReadStates.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
return deleted.length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => {
|
archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export const issuesApi = {
|
||||||
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
|
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
|
||||||
get: (id: string) => api.get<Issue>(`/issues/${id}`),
|
get: (id: string) => api.get<Issue>(`/issues/${id}`),
|
||||||
markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}),
|
markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}),
|
||||||
|
markUnread: (id: string) => api.delete<{ id: string; removed: boolean }>(`/issues/${id}/read`),
|
||||||
archiveFromInbox: (id: string) =>
|
archiveFromInbox: (id: string) =>
|
||||||
api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}),
|
api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}),
|
||||||
unarchiveFromInbox: (id: string) =>
|
unarchiveFromInbox: (id: string) =>
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,16 @@ export function useReadInboxItems() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return { readItems, markRead };
|
const markUnread = (id: string) => {
|
||||||
|
setReadItems((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
saveReadInboxItems(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { readItems, markRead, markUnread };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInboxBadge(companyId: string | null | undefined) {
|
export function useInboxBadge(companyId: string | null | undefined) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { approvalsApi } from "../api/approvals";
|
import { approvalsApi } from "../api/approvals";
|
||||||
|
|
@ -52,6 +52,7 @@ import {
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
shouldShowInboxSection,
|
shouldShowInboxSection,
|
||||||
type InboxTab,
|
type InboxTab,
|
||||||
|
type InboxWorkItem,
|
||||||
} from "../lib/inbox";
|
} from "../lib/inbox";
|
||||||
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
|
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||||
|
|
||||||
|
|
@ -512,7 +513,7 @@ export function Inbox() {
|
||||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||||
const { dismissed, dismiss } = useDismissedInboxItems();
|
const { dismissed, dismiss } = useDismissedInboxItems();
|
||||||
const { readItems, markRead: markItemRead } = useReadInboxItems();
|
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
||||||
|
|
||||||
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
||||||
const tab: InboxTab =
|
const tab: InboxTab =
|
||||||
|
|
@ -540,6 +541,7 @@ export function Inbox() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveLastInboxTab(tab);
|
saveLastInboxTab(tab);
|
||||||
|
setSelectedIndex(-1);
|
||||||
}, [tab]);
|
}, [tab]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -793,6 +795,8 @@ export function Inbox() {
|
||||||
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
|
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
|
||||||
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
|
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
|
||||||
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
|
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const invalidateInboxIssueQueries = () => {
|
const invalidateInboxIssueQueries = () => {
|
||||||
if (!selectedCompanyId) return;
|
if (!selectedCompanyId) return;
|
||||||
|
|
@ -875,7 +879,14 @@ export function Inbox() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleMarkNonIssueRead = (key: string) => {
|
const markUnreadMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => issuesApi.markUnread(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateInboxIssueQueries();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMarkNonIssueRead = useCallback((key: string) => {
|
||||||
setFadingNonIssueItems((prev) => new Set(prev).add(key));
|
setFadingNonIssueItems((prev) => new Set(prev).add(key));
|
||||||
markItemRead(key);
|
markItemRead(key);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -885,9 +896,9 @@ export function Inbox() {
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, 300);
|
}, 300);
|
||||||
};
|
}, [markItemRead]);
|
||||||
|
|
||||||
const handleArchiveNonIssue = (key: string) => {
|
const handleArchiveNonIssue = useCallback((key: string) => {
|
||||||
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
|
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
dismiss(key);
|
dismiss(key);
|
||||||
|
|
@ -897,7 +908,7 @@ export function Inbox() {
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, 200);
|
}, 200);
|
||||||
};
|
}, [dismiss]);
|
||||||
|
|
||||||
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
|
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
|
||||||
if (tab !== "mine") return null;
|
if (tab !== "mine") return null;
|
||||||
|
|
@ -908,6 +919,133 @@ export function Inbox() {
|
||||||
return "hidden";
|
return "hidden";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getWorkItemKey = useCallback((item: InboxWorkItem): string => {
|
||||||
|
if (item.kind === "issue") return `issue:${item.issue.id}`;
|
||||||
|
if (item.kind === "approval") return `approval:${item.approval.id}`;
|
||||||
|
if (item.kind === "failed_run") return `run:${item.run.id}`;
|
||||||
|
return `join:${item.joinRequest.id}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset selection when the list changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
prev >= workItemsToRender.length ? workItemsToRender.length - 1 : prev,
|
||||||
|
);
|
||||||
|
}, [workItemsToRender.length]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts (mail-client style)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Don't capture when typing in inputs/textareas or with modifier keys
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.tagName === "SELECT" ||
|
||||||
|
target.isContentEditable ||
|
||||||
|
e.metaKey ||
|
||||||
|
e.ctrlKey ||
|
||||||
|
e.altKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemCount = workItemsToRender.length;
|
||||||
|
if (itemCount === 0) return;
|
||||||
|
|
||||||
|
const isMineTab = tab === "mine";
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "j": {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => Math.min(prev + 1, itemCount - 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "k": {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "a": {
|
||||||
|
if (!isMineTab || selectedIndex < 0 || selectedIndex >= itemCount) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const item = workItemsToRender[selectedIndex];
|
||||||
|
const key = getWorkItemKey(item);
|
||||||
|
if (item.kind === "issue") {
|
||||||
|
if (!archivingIssueIds.has(item.issue.id)) {
|
||||||
|
archiveIssueMutation.mutate(item.issue.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!archivingNonIssueIds.has(key)) {
|
||||||
|
handleArchiveNonIssue(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "U": {
|
||||||
|
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const item = workItemsToRender[selectedIndex];
|
||||||
|
if (item.kind === "issue") {
|
||||||
|
markUnreadMutation.mutate(item.issue.id);
|
||||||
|
} else {
|
||||||
|
const key = getWorkItemKey(item);
|
||||||
|
markItemUnread(key);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "r": {
|
||||||
|
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const item = workItemsToRender[selectedIndex];
|
||||||
|
if (item.kind === "issue") {
|
||||||
|
if (item.issue.isUnreadForMe && !fadingOutIssues.has(item.issue.id)) {
|
||||||
|
markReadMutation.mutate(item.issue.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const key = getWorkItemKey(item);
|
||||||
|
if (!readItems.has(key)) {
|
||||||
|
handleMarkNonIssueRead(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "Enter": {
|
||||||
|
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const item = workItemsToRender[selectedIndex];
|
||||||
|
if (item.kind === "issue") {
|
||||||
|
const pathId = item.issue.identifier ?? item.issue.id;
|
||||||
|
navigate(`/issues/${pathId}`, { state: issueLinkState });
|
||||||
|
} else if (item.kind === "approval") {
|
||||||
|
navigate(`/approvals/${item.approval.id}`);
|
||||||
|
} else if (item.kind === "failed_run") {
|
||||||
|
navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [
|
||||||
|
workItemsToRender, selectedIndex, tab, navigate, issueLinkState,
|
||||||
|
getWorkItemKey, archivingIssueIds, archivingNonIssueIds,
|
||||||
|
fadingOutIssues, readItems,
|
||||||
|
archiveIssueMutation, markReadMutation, markUnreadMutation,
|
||||||
|
handleArchiveNonIssue, handleMarkNonIssueRead, markItemUnread,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Scroll selected item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIndex < 0 || !listRef.current) return;
|
||||||
|
const rows = listRef.current.querySelectorAll("[data-inbox-item]");
|
||||||
|
const row = rows[selectedIndex];
|
||||||
|
if (row) row.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
||||||
}
|
}
|
||||||
|
|
@ -1050,8 +1188,18 @@ export function Inbox() {
|
||||||
<>
|
<>
|
||||||
{showSeparatorBefore("work_items") && <Separator />}
|
{showSeparatorBefore("work_items") && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
|
||||||
{workItemsToRender.flatMap((item, index) => {
|
{workItemsToRender.flatMap((item, index) => {
|
||||||
|
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
||||||
|
<div
|
||||||
|
key={`sel-${key}`}
|
||||||
|
data-inbox-item
|
||||||
|
className={isSelected ? "ring-2 ring-inset ring-primary/40 rounded-sm" : ""}
|
||||||
|
onClick={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
const todayCutoff = Date.now() - 24 * 60 * 60 * 1000;
|
const todayCutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||||
const showTodayDivider =
|
const showTodayDivider =
|
||||||
index > 0 &&
|
index > 0 &&
|
||||||
|
|
@ -1070,6 +1218,7 @@ export function Inbox() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const isMineTab = tab === "mine";
|
const isMineTab = tab === "mine";
|
||||||
|
const isSelected = selectedIndex === index;
|
||||||
|
|
||||||
if (item.kind === "approval") {
|
if (item.kind === "approval") {
|
||||||
const approvalKey = `approval:${item.approval.id}`;
|
const approvalKey = `approval:${item.approval.id}`;
|
||||||
|
|
@ -1093,7 +1242,7 @@ export function Inbox() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
elements.push(isMineTab ? (
|
elements.push(wrapItem(approvalKey, isSelected, isMineTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={approvalKey}
|
key={approvalKey}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
|
|
@ -1101,7 +1250,7 @@ export function Inbox() {
|
||||||
>
|
>
|
||||||
{row}
|
{row}
|
||||||
</SwipeToArchive>
|
</SwipeToArchive>
|
||||||
) : row);
|
) : row));
|
||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1129,7 +1278,7 @@ export function Inbox() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
elements.push(isMineTab ? (
|
elements.push(wrapItem(runKey, isSelected, isMineTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={runKey}
|
key={runKey}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
|
|
@ -1137,7 +1286,7 @@ export function Inbox() {
|
||||||
>
|
>
|
||||||
{row}
|
{row}
|
||||||
</SwipeToArchive>
|
</SwipeToArchive>
|
||||||
) : row);
|
) : row));
|
||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1162,7 +1311,7 @@ export function Inbox() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
elements.push(isMineTab ? (
|
elements.push(wrapItem(joinKey, isSelected, isMineTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={joinKey}
|
key={joinKey}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
|
|
@ -1170,7 +1319,7 @@ export function Inbox() {
|
||||||
>
|
>
|
||||||
{row}
|
{row}
|
||||||
</SwipeToArchive>
|
</SwipeToArchive>
|
||||||
) : row);
|
) : row));
|
||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1232,7 +1381,7 @@ export function Inbox() {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
elements.push(isMineTab ? (
|
elements.push(wrapItem(`issue:${issue.id}`, isSelected, isMineTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={`issue:${issue.id}`}
|
key={`issue:${issue.id}`}
|
||||||
disabled={isArchiving || archiveIssueMutation.isPending}
|
disabled={isArchiving || archiveIssueMutation.isPending}
|
||||||
|
|
@ -1240,7 +1389,7 @@ export function Inbox() {
|
||||||
>
|
>
|
||||||
{row}
|
{row}
|
||||||
</SwipeToArchive>
|
</SwipeToArchive>
|
||||||
) : row);
|
) : row));
|
||||||
return elements;
|
return elements;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue