Persist issue read state and clear unread on open
This commit is contained in:
15
packages/db/src/migrations/0025_nasty_salo.sql
Normal file
15
packages/db/src/migrations/0025_nasty_salo.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE "issue_read_states" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"issue_id" uuid NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"last_read_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_read_states" ADD CONSTRAINT "issue_read_states_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_read_states" ADD CONSTRAINT "issue_read_states_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "issue_read_states_company_issue_idx" ON "issue_read_states" USING btree ("company_id","issue_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "issue_read_states_company_user_idx" ON "issue_read_states" USING btree ("company_id","user_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "issue_read_states_company_issue_user_idx" ON "issue_read_states" USING btree ("company_id","issue_id","user_id");
|
||||||
5849
packages/db/src/migrations/meta/0025_snapshot.json
Normal file
5849
packages/db/src/migrations/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,13 @@
|
|||||||
"when": 1772806603601,
|
"when": 1772806603601,
|
||||||
"tag": "0024_far_beast",
|
"tag": "0024_far_beast",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772807461603,
|
||||||
|
"tag": "0025_nasty_salo",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ export { labels } from "./labels.js";
|
|||||||
export { issueLabels } from "./issue_labels.js";
|
export { issueLabels } from "./issue_labels.js";
|
||||||
export { issueApprovals } from "./issue_approvals.js";
|
export { issueApprovals } from "./issue_approvals.js";
|
||||||
export { issueComments } from "./issue_comments.js";
|
export { issueComments } from "./issue_comments.js";
|
||||||
|
export { issueReadStates } from "./issue_read_states.js";
|
||||||
export { assets } from "./assets.js";
|
export { assets } from "./assets.js";
|
||||||
export { issueAttachments } from "./issue_attachments.js";
|
export { issueAttachments } from "./issue_attachments.js";
|
||||||
export { heartbeatRuns } from "./heartbeat_runs.js";
|
export { heartbeatRuns } from "./heartbeat_runs.js";
|
||||||
|
|||||||
25
packages/db/src/schema/issue_read_states.ts
Normal file
25
packages/db/src/schema/issue_read_states.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { issues } from "./issues.js";
|
||||||
|
|
||||||
|
export const issueReadStates = pgTable(
|
||||||
|
"issue_read_states",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
issueId: uuid("issue_id").notNull().references(() => issues.id),
|
||||||
|
userId: text("user_id").notNull(),
|
||||||
|
lastReadAt: timestamp("last_read_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyIssueIdx: index("issue_read_states_company_issue_idx").on(table.companyId, table.issueId),
|
||||||
|
companyUserIdx: index("issue_read_states_company_user_idx").on(table.companyId, table.userId),
|
||||||
|
companyIssueUserUnique: uniqueIndex("issue_read_states_company_issue_user_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.issueId,
|
||||||
|
table.userId,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -23,6 +23,7 @@ describe("deriveIssueUserContext", () => {
|
|||||||
"user-1",
|
"user-1",
|
||||||
{
|
{
|
||||||
myLastCommentAt: new Date("2026-03-06T12:00:00.000Z"),
|
myLastCommentAt: new Date("2026-03-06T12:00:00.000Z"),
|
||||||
|
myLastReadAt: null,
|
||||||
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
|
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -38,6 +39,7 @@ describe("deriveIssueUserContext", () => {
|
|||||||
"user-1",
|
"user-1",
|
||||||
{
|
{
|
||||||
myLastCommentAt: new Date("2026-03-06T14:00:00.000Z"),
|
myLastCommentAt: new Date("2026-03-06T14:00:00.000Z"),
|
||||||
|
myLastReadAt: null,
|
||||||
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
|
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -51,6 +53,7 @@ describe("deriveIssueUserContext", () => {
|
|||||||
"user-1",
|
"user-1",
|
||||||
{
|
{
|
||||||
myLastCommentAt: null,
|
myLastCommentAt: null,
|
||||||
|
myLastReadAt: null,
|
||||||
lastExternalCommentAt: new Date("2026-03-06T10:00:00.000Z"),
|
lastExternalCommentAt: new Date("2026-03-06T10:00:00.000Z"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -65,6 +68,7 @@ describe("deriveIssueUserContext", () => {
|
|||||||
"user-1",
|
"user-1",
|
||||||
{
|
{
|
||||||
myLastCommentAt: null,
|
myLastCommentAt: null,
|
||||||
|
myLastReadAt: null,
|
||||||
lastExternalCommentAt: new Date("2026-03-06T14:59:00.000Z"),
|
lastExternalCommentAt: new Date("2026-03-06T14:59:00.000Z"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -72,4 +76,19 @@ describe("deriveIssueUserContext", () => {
|
|||||||
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T15:00:00.000Z");
|
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T15:00:00.000Z");
|
||||||
expect(context.isUnreadForMe).toBe(false);
|
expect(context.isUnreadForMe).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses latest read timestamp to clear unread without requiring a comment", () => {
|
||||||
|
const context = deriveIssueUserContext(
|
||||||
|
makeIssue({ createdByUserId: "user-1", createdAt: new Date("2026-03-06T09:00:00.000Z") }),
|
||||||
|
"user-1",
|
||||||
|
{
|
||||||
|
myLastCommentAt: null,
|
||||||
|
myLastReadAt: new Date("2026-03-06T11:30:00.000Z"),
|
||||||
|
lastExternalCommentAt: new Date("2026-03-06T11:00:00.000Z"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T11:30:00.000Z");
|
||||||
|
expect(context.isUnreadForMe).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -303,6 +303,38 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects });
|
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/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 readState = await svc.markRead(issue.companyId, issue.id, req.actor.userId, new Date());
|
||||||
|
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_marked",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: { userId: req.actor.userId, lastReadAt: readState.lastReadAt },
|
||||||
|
});
|
||||||
|
res.json(readState);
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/issues/:id/approvals", async (req, res) => {
|
router.get("/issues/:id/approvals", 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);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
issueAttachments,
|
issueAttachments,
|
||||||
issueLabels,
|
issueLabels,
|
||||||
issueComments,
|
issueComments,
|
||||||
|
issueReadStates,
|
||||||
issues,
|
issues,
|
||||||
labels,
|
labels,
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
@@ -98,6 +99,13 @@ function touchedByUserCondition(companyId: string, userId: string) {
|
|||||||
(
|
(
|
||||||
${issues.createdByUserId} = ${userId}
|
${issues.createdByUserId} = ${userId}
|
||||||
OR ${issues.assigneeUserId} = ${userId}
|
OR ${issues.assigneeUserId} = ${userId}
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM ${issueReadStates}
|
||||||
|
WHERE ${issueReadStates.issueId} = ${issues.id}
|
||||||
|
AND ${issueReadStates.companyId} = ${companyId}
|
||||||
|
AND ${issueReadStates.userId} = ${userId}
|
||||||
|
)
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM ${issueComments}
|
FROM ${issueComments}
|
||||||
@@ -121,13 +129,27 @@ function myLastCommentAtExpr(companyId: string, userId: string) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function myLastReadAtExpr(companyId: string, userId: string) {
|
||||||
|
return sql<Date | null>`
|
||||||
|
(
|
||||||
|
SELECT MAX(${issueReadStates.lastReadAt})
|
||||||
|
FROM ${issueReadStates}
|
||||||
|
WHERE ${issueReadStates.issueId} = ${issues.id}
|
||||||
|
AND ${issueReadStates.companyId} = ${companyId}
|
||||||
|
AND ${issueReadStates.userId} = ${userId}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function myLastTouchAtExpr(companyId: string, userId: string) {
|
function myLastTouchAtExpr(companyId: string, userId: string) {
|
||||||
const myLastCommentAt = myLastCommentAtExpr(companyId, userId);
|
const myLastCommentAt = myLastCommentAtExpr(companyId, userId);
|
||||||
|
const myLastReadAt = myLastReadAtExpr(companyId, userId);
|
||||||
return sql<Date | null>`
|
return sql<Date | null>`
|
||||||
COALESCE(
|
GREATEST(
|
||||||
${myLastCommentAt},
|
COALESCE(${myLastCommentAt}, to_timestamp(0)),
|
||||||
CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END,
|
COALESCE(${myLastReadAt}, to_timestamp(0)),
|
||||||
CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END
|
COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)),
|
||||||
|
COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0))
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -156,13 +178,18 @@ function unreadForUserCondition(companyId: string, userId: string) {
|
|||||||
export function deriveIssueUserContext(
|
export function deriveIssueUserContext(
|
||||||
issue: IssueUserContextInput,
|
issue: IssueUserContextInput,
|
||||||
userId: string,
|
userId: string,
|
||||||
stats: { myLastCommentAt: Date | null; lastExternalCommentAt: Date | null } | null | undefined,
|
stats:
|
||||||
|
| { myLastCommentAt: Date | null; myLastReadAt: Date | null; lastExternalCommentAt: Date | null }
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
) {
|
) {
|
||||||
const myLastCommentAt = stats?.myLastCommentAt ?? null;
|
const myLastCommentAt = stats?.myLastCommentAt ?? null;
|
||||||
const myLastTouchAt =
|
const myLastReadAt = stats?.myLastReadAt ?? null;
|
||||||
myLastCommentAt ??
|
const createdTouchAt = issue.createdByUserId === userId ? issue.createdAt : null;
|
||||||
(issue.createdByUserId === userId ? issue.createdAt : null) ??
|
const assignedTouchAt = issue.assigneeUserId === userId ? issue.updatedAt : null;
|
||||||
(issue.assigneeUserId === userId ? issue.updatedAt : null);
|
const myLastTouchAt = [myLastCommentAt, myLastReadAt, createdTouchAt, assignedTouchAt]
|
||||||
|
.filter((value): value is Date => value instanceof Date)
|
||||||
|
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null;
|
||||||
const lastExternalCommentAt = stats?.lastExternalCommentAt ?? null;
|
const lastExternalCommentAt = stats?.lastExternalCommentAt ?? null;
|
||||||
const isUnreadForMe = Boolean(
|
const isUnreadForMe = Boolean(
|
||||||
myLastTouchAt &&
|
myLastTouchAt &&
|
||||||
@@ -488,11 +515,29 @@ export function issueService(db: Db) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(issueComments.issueId);
|
.groupBy(issueComments.issueId);
|
||||||
|
const readRows = await db
|
||||||
|
.select({
|
||||||
|
issueId: issueReadStates.issueId,
|
||||||
|
myLastReadAt: issueReadStates.lastReadAt,
|
||||||
|
})
|
||||||
|
.from(issueReadStates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(issueReadStates.companyId, companyId),
|
||||||
|
eq(issueReadStates.userId, contextUserId),
|
||||||
|
inArray(issueReadStates.issueId, issueIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
||||||
|
const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt]));
|
||||||
|
|
||||||
return withRuns.map((row) => ({
|
return withRuns.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
...deriveIssueUserContext(row, contextUserId, statsByIssueId.get(row.id)),
|
...deriveIssueUserContext(row, contextUserId, {
|
||||||
|
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||||
|
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
||||||
|
lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -517,6 +562,28 @@ export function issueService(db: Db) {
|
|||||||
return Number(row?.count ?? 0);
|
return Number(row?.count ?? 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
markRead: async (companyId: string, issueId: string, userId: string, readAt: Date = new Date()) => {
|
||||||
|
const now = new Date();
|
||||||
|
const [row] = await db
|
||||||
|
.insert(issueReadStates)
|
||||||
|
.values({
|
||||||
|
companyId,
|
||||||
|
issueId,
|
||||||
|
userId,
|
||||||
|
lastReadAt: readAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [issueReadStates.companyId, issueReadStates.issueId, issueReadStates.userId],
|
||||||
|
set: {
|
||||||
|
lastReadAt: readAt,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
|
||||||
getById: async (id: string) => {
|
getById: async (id: string) => {
|
||||||
const row = await db
|
const row = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const issuesApi = {
|
|||||||
api.post<IssueLabel>(`/companies/${companyId}/labels`, data),
|
api.post<IssueLabel>(`/companies/${companyId}/labels`, data),
|
||||||
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`, {}),
|
||||||
create: (companyId: string, data: Record<string, unknown>) =>
|
create: (companyId: string, data: Record<string, unknown>) =>
|
||||||
api.post<Issue>(`/companies/${companyId}/issues`, data),
|
api.post<Issue>(`/companies/${companyId}/issues`, data),
|
||||||
update: (id: string, data: Record<string, unknown>) => api.patch<Issue>(`/issues/${id}`, data),
|
update: (id: string, data: Record<string, unknown>) => api.patch<Issue>(`/issues/${id}`, data),
|
||||||
|
|||||||
@@ -361,18 +361,6 @@ export function Inbox() {
|
|||||||
}),
|
}),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
const {
|
|
||||||
data: unreadTouchedIssuesRaw = [],
|
|
||||||
isLoading: isUnreadTouchedIssuesLoading,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!),
|
|
||||||
queryFn: () =>
|
|
||||||
issuesApi.list(selectedCompanyId!, {
|
|
||||||
unreadForUserId: "me",
|
|
||||||
status: "backlog,todo,in_progress,in_review,blocked",
|
|
||||||
}),
|
|
||||||
enabled: !!selectedCompanyId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({
|
const { data: heartbeatRuns, isLoading: isRunsLoading } = useQuery({
|
||||||
queryKey: queryKeys.heartbeats(selectedCompanyId!),
|
queryKey: queryKeys.heartbeats(selectedCompanyId!),
|
||||||
@@ -395,10 +383,6 @@ export function Inbox() {
|
|||||||
() => [...touchedIssuesRaw].sort(sortByRecentExternalComment),
|
() => [...touchedIssuesRaw].sort(sortByRecentExternalComment),
|
||||||
[sortByRecentExternalComment, touchedIssuesRaw],
|
[sortByRecentExternalComment, touchedIssuesRaw],
|
||||||
);
|
);
|
||||||
const unreadTouchedIssues = useMemo(
|
|
||||||
() => [...unreadTouchedIssuesRaw].sort(sortByRecentExternalComment),
|
|
||||||
[sortByRecentExternalComment, unreadTouchedIssuesRaw],
|
|
||||||
);
|
|
||||||
|
|
||||||
const agentById = useMemo(() => {
|
const agentById = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -510,10 +494,10 @@ export function Inbox() {
|
|||||||
const hasStale = staleIssues.length > 0;
|
const hasStale = staleIssues.length > 0;
|
||||||
const hasJoinRequests = joinRequests.length > 0;
|
const hasJoinRequests = joinRequests.length > 0;
|
||||||
const hasTouchedIssues = touchedIssues.length > 0;
|
const hasTouchedIssues = touchedIssues.length > 0;
|
||||||
const hasUnreadTouchedIssues = unreadTouchedIssues.length > 0;
|
const unreadTouchedCount = touchedIssues.filter((issue) => issue.isUnreadForMe).length;
|
||||||
|
|
||||||
const newItemCount =
|
const newItemCount =
|
||||||
unreadTouchedIssues.length +
|
unreadTouchedCount +
|
||||||
joinRequests.length +
|
joinRequests.length +
|
||||||
actionableApprovals.length +
|
actionableApprovals.length +
|
||||||
failedRuns.length +
|
failedRuns.length +
|
||||||
@@ -532,8 +516,7 @@ export function Inbox() {
|
|||||||
const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work";
|
const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work";
|
||||||
|
|
||||||
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
|
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
|
||||||
const showTouchedSection =
|
const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues;
|
||||||
tab === "new" ? hasUnreadTouchedIssues : showTouchedCategory && hasTouchedIssues;
|
|
||||||
const showJoinRequestsSection =
|
const showJoinRequestsSection =
|
||||||
tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests;
|
tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests;
|
||||||
const showApprovalsSection =
|
const showApprovalsSection =
|
||||||
@@ -560,7 +543,6 @@ export function Inbox() {
|
|||||||
!isDashboardLoading &&
|
!isDashboardLoading &&
|
||||||
!isIssuesLoading &&
|
!isIssuesLoading &&
|
||||||
!isTouchedIssuesLoading &&
|
!isTouchedIssuesLoading &&
|
||||||
!isUnreadTouchedIssuesLoading &&
|
|
||||||
!isRunsLoading;
|
!isRunsLoading;
|
||||||
|
|
||||||
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
||||||
@@ -640,7 +622,7 @@ export function Inbox() {
|
|||||||
icon={InboxIcon}
|
icon={InboxIcon}
|
||||||
message={
|
message={
|
||||||
tab === "new"
|
tab === "new"
|
||||||
? "No unread updates on issues you're involved in."
|
? "No issues you're involved in yet."
|
||||||
: "No inbox items match these filters."
|
: "No inbox items match these filters."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -654,7 +636,7 @@ export function Inbox() {
|
|||||||
Issues I Touched
|
Issues I Touched
|
||||||
</h3>
|
</h3>
|
||||||
<div className="divide-y divide-border border border-border">
|
<div className="divide-y divide-border border border-border">
|
||||||
{(tab === "new" ? unreadTouchedIssues : touchedIssues).map((issue) => (
|
{touchedIssues.map((issue) => (
|
||||||
<Link
|
<Link
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
@@ -667,17 +649,15 @@ export function Inbox() {
|
|||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
||||||
{tab === "all" && (
|
<span
|
||||||
<span
|
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||||
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
issue.isUnreadForMe
|
||||||
issue.isUnreadForMe
|
? "bg-blue-500/20 text-blue-600 dark:text-blue-400"
|
||||||
? "bg-blue-500/20 text-blue-600 dark:text-blue-400"
|
: "bg-muted text-muted-foreground"
|
||||||
: "bg-muted text-muted-foreground"
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
{issue.isUnreadForMe ? "Unread" : "Read"}
|
||||||
{issue.isUnreadForMe ? "Unread" : "Read"}
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
{issue.lastExternalCommentAt
|
{issue.lastExternalCommentAt
|
||||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export function IssueDetail() {
|
|||||||
});
|
});
|
||||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const { data: issue, isLoading, error } = useQuery({
|
const { data: issue, isLoading, error } = useQuery({
|
||||||
queryKey: queryKeys.issues.detail(issueId!),
|
queryKey: queryKeys.issues.detail(issueId!),
|
||||||
@@ -383,9 +384,23 @@ export function IssueDetail() {
|
|||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
||||||
if (selectedCompanyId) {
|
if (selectedCompanyId) {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const markIssueRead = useMutation({
|
||||||
|
mutationFn: (id: string) => issuesApi.markRead(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (selectedCompanyId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const updateIssue = useMutation({
|
const updateIssue = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
|
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
@@ -490,6 +505,13 @@ export function IssueDetail() {
|
|||||||
}
|
}
|
||||||
}, [issue, issueId, navigate]);
|
}, [issue, issueId, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!issue?.id) return;
|
||||||
|
if (lastMarkedReadIssueIdRef.current === issue.id) return;
|
||||||
|
lastMarkedReadIssueIdRef.current = issue.id;
|
||||||
|
markIssueRead.mutate(issue.id);
|
||||||
|
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (issue) {
|
if (issue) {
|
||||||
openPanel(
|
openPanel(
|
||||||
|
|||||||
Reference in New Issue
Block a user