Align inbox badge with visible unread items
This commit is contained in:
@@ -9,8 +9,10 @@ import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
computeInboxBadgeData,
|
||||
getRecentTouchedIssues,
|
||||
loadDismissedInboxItems,
|
||||
saveDismissedInboxItems,
|
||||
getUnreadTouchedIssues,
|
||||
} from "../lib/inbox";
|
||||
|
||||
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
||||
@@ -70,16 +72,21 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const { data: unreadIssues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId!),
|
||||
const { data: touchedIssues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.listTouchedByMe(companyId!),
|
||||
queryFn: () =>
|
||||
issuesApi.list(companyId!, {
|
||||
unreadForUserId: "me",
|
||||
touchedByUserId: "me",
|
||||
status: INBOX_ISSUE_STATUSES,
|
||||
}),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const unreadIssues = useMemo(
|
||||
() => getUnreadTouchedIssues(getRecentTouchedIssues(touchedIssues)),
|
||||
[touchedIssues],
|
||||
);
|
||||
|
||||
const { data: heartbeatRuns = [] } = useQuery({
|
||||
queryKey: queryKeys.heartbeats(companyId!),
|
||||
queryFn: () => heartbeatsApi.list(companyId!),
|
||||
|
||||
@@ -4,8 +4,10 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
import {
|
||||
computeInboxBadgeData,
|
||||
getRecentTouchedIssues,
|
||||
getUnreadTouchedIssues,
|
||||
loadLastInboxTab,
|
||||
RECENT_ISSUES_LIMIT,
|
||||
saveLastInboxTab,
|
||||
} from "./inbox";
|
||||
|
||||
@@ -220,6 +222,19 @@ describe("inbox helpers", () => {
|
||||
expect(issues).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("limits recent touched issues before unread badge counting", () => {
|
||||
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
|
||||
const issue = makeIssue(String(index + 1), index < 3);
|
||||
issue.lastExternalCommentAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000);
|
||||
return issue;
|
||||
});
|
||||
|
||||
const recentIssues = getRecentTouchedIssues(issues);
|
||||
|
||||
expect(recentIssues).toHaveLength(RECENT_ISSUES_LIMIT);
|
||||
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("defaults the remembered inbox tab to recent and persists all", () => {
|
||||
localStorage.clear();
|
||||
expect(loadLastInboxTab()).toBe("recent");
|
||||
|
||||
@@ -96,6 +96,10 @@ export function sortIssuesByMostRecentActivity(a: Issue, b: Issue): number {
|
||||
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
|
||||
}
|
||||
|
||||
export function getRecentTouchedIssues(issues: Issue[]): Issue[] {
|
||||
return [...issues].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT);
|
||||
}
|
||||
|
||||
export function getUnreadTouchedIssues(issues: Issue[]): Issue[] {
|
||||
return issues.filter((issue) => issue.isUnreadForMe);
|
||||
}
|
||||
|
||||
@@ -43,10 +43,9 @@ import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
import {
|
||||
ACTIONABLE_APPROVAL_STATUSES,
|
||||
getLatestFailedRunsByAgent,
|
||||
getRecentTouchedIssues,
|
||||
type InboxTab,
|
||||
RECENT_ISSUES_LIMIT,
|
||||
saveLastInboxTab,
|
||||
sortIssuesByMostRecentActivity,
|
||||
} from "../lib/inbox";
|
||||
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
||||
|
||||
@@ -329,10 +328,7 @@ export function Inbox() {
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const touchedIssues = useMemo(
|
||||
() => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
|
||||
[touchedIssuesRaw],
|
||||
);
|
||||
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
|
||||
const unreadTouchedIssues = useMemo(
|
||||
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||
[touchedIssues],
|
||||
@@ -435,17 +431,20 @@ export function Inbox() {
|
||||
|
||||
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
|
||||
|
||||
const invalidateInboxIssueQueries = () => {
|
||||
if (!selectedCompanyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||
};
|
||||
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: (id: string) => issuesApi.markRead(id),
|
||||
onMutate: (id) => {
|
||||
setFadingOutIssues((prev) => new Set(prev).add(id));
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||
}
|
||||
invalidateInboxIssueQueries();
|
||||
},
|
||||
onSettled: (_data, _error, id) => {
|
||||
setTimeout(() => {
|
||||
@@ -458,6 +457,31 @@ export function Inbox() {
|
||||
},
|
||||
});
|
||||
|
||||
const markAllReadMutation = useMutation({
|
||||
mutationFn: async (issueIds: string[]) => {
|
||||
await Promise.all(issueIds.map((issueId) => issuesApi.markRead(issueId)));
|
||||
},
|
||||
onMutate: (issueIds) => {
|
||||
setFadingOutIssues((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const issueId of issueIds) next.add(issueId);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateInboxIssueQueries();
|
||||
},
|
||||
onSettled: (_data, _error, issueIds) => {
|
||||
setTimeout(() => {
|
||||
setFadingOutIssues((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const issueId of issueIds) next.delete(issueId);
|
||||
return next;
|
||||
});
|
||||
}, 300);
|
||||
},
|
||||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
||||
}
|
||||
@@ -515,6 +539,10 @@ export function Inbox() {
|
||||
!isRunsLoading;
|
||||
|
||||
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
||||
const unreadIssueIds = unreadTouchedIssues
|
||||
.filter((issue) => !fadingOutIssues.has(issue.id))
|
||||
.map((issue) => issue.id);
|
||||
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -532,8 +560,22 @@ export function Inbox() {
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{tab === "all" && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canMarkAllRead && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
>
|
||||
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab === "all" && (
|
||||
<>
|
||||
<Select
|
||||
value={allCategoryFilter}
|
||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
||||
@@ -566,8 +608,9 @@ export function Inbox() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
||||
|
||||
Reference in New Issue
Block a user