diff --git a/apps/dashboard/src/components/inbox/inbox-page.tsx b/apps/dashboard/src/components/inbox/inbox-page.tsx new file mode 100644 index 0000000..86124a5 --- /dev/null +++ b/apps/dashboard/src/components/inbox/inbox-page.tsx @@ -0,0 +1,839 @@ +import { + ArchiveDownIcon, + CheckIcon, + CommentIcon, + ExternalLinkIcon, + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestIcon, + InboxIcon, + IssuesIcon, + MoreHorizontalIcon, + NotificationIcon, +} from "@diffkit/icons"; +import { Button } from "@diffkit/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@diffkit/ui/components/dropdown-menu"; +import { Spinner } from "@diffkit/ui/components/spinner"; +import { cn } from "@diffkit/ui/lib/utils"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getRouteApi, Link } from "@tanstack/react-router"; +import { AnimatePresence, motion } from "motion/react"; +import { memo, useCallback, useMemo, useState } from "react"; +import { IssueDetailContent } from "#/components/issues/detail/issue-detail-page"; +import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; +import { PullDetailContent } from "#/components/pulls/detail/pull-detail-page"; +import { formatRelativeTime } from "#/lib/format-relative-time"; +import { + markAllNotificationsRead, + markNotificationDone, + markNotificationRead, +} from "#/lib/github.functions"; +import { + type GitHubQueryScope, + githubIssuePageQueryOptions, + githubNotificationsQueryOptions, + githubPullPageQueryOptions, + githubQueryKeys, +} from "#/lib/github.query"; +import type { + NotificationItem, + NotificationParticipant, + NotificationsResult, +} from "#/lib/github.types"; +import { useHasMounted } from "#/lib/use-has-mounted"; + +const routeApi = getRouteApi("/_protected/inbox"); + +type InboxFilter = "unread" | "all"; + +const AVATAR_MAX_VISIBLE = 4; +const AVATAR_SIZE = 24; +const AVATAR_OVERLAP = 8; + +// Hoisted regexes — avoids recreation per call (js-hoist-regexp) +const RE_PULLS_NUMBER = /\/(?:pulls|issues)\/(\d+)$/; +const RE_API_PULLS = /\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)$/; +const RE_API_ISSUES = /\/repos\/[^/]+\/[^/]+\/issues\/(\d+)$/; +const RE_API_COMMITS = /\/repos\/[^/]+\/[^/]+\/commits\/([a-f0-9]+)$/; +const RE_API_RELEASES = /\/repos\/[^/]+\/[^/]+\/releases\/(\d+)$/; + +function AvatarStack({ + participants, +}: { + participants: NotificationParticipant[] | undefined; +}) { + if (!participants || participants.length === 0) return null; + + const visible = participants.slice(0, AVATAR_MAX_VISIBLE); + const overflow = participants.length - AVATAR_MAX_VISIBLE; + const totalWidth = + AVATAR_SIZE + + (visible.length - 1 + (overflow > 0 ? 1 : 0)) * + (AVATAR_SIZE - AVATAR_OVERLAP); + + return ( +
+ {visible.map((p, i) => ( + {p.login} + ))} + {overflow > 0 && ( +
+ +{overflow} +
+ )} +
+ ); +} + +export function InboxPage() { + const { user } = routeApi.useRouteContext(); + const scope = useMemo(() => ({ userId: user.id }), [user.id]); + const hasMounted = useHasMounted(); + const [selectedId, setSelectedId] = useState(null); + const [filter, setFilter] = useState("unread"); + + const queryInput = { all: filter === "all" }; + const query = useQuery({ + ...githubNotificationsQueryOptions(scope, queryInput), + enabled: hasMounted, + }); + + const queryClient = useQueryClient(); + const notifications = query.data?.notifications ?? []; + const selected = notifications.find((n) => n.id === selectedId) ?? null; + + const handleSelect = useCallback( + (notification: NotificationItem) => { + setSelectedId(notification.id); + + // Prefetch the next notification's detail data + const idx = notifications.findIndex((n) => n.id === notification.id); + const next = idx >= 0 ? notifications[idx + 1] : null; + if (next) { + const parsed = parseSubjectRef(next); + if (parsed?.type === "PullRequest") { + queryClient.prefetchQuery( + githubPullPageQueryOptions(scope, { + owner: parsed.owner, + repo: parsed.repo, + pullNumber: parsed.number, + }), + ); + } else if (parsed?.type === "Issue") { + queryClient.prefetchQuery( + githubIssuePageQueryOptions(scope, { + owner: parsed.owner, + repo: parsed.repo, + issueNumber: parsed.number, + }), + ); + } + } + }, + [notifications, queryClient, scope], + ); + + if (query.error) throw query.error; + if (!hasMounted || (!query.data && query.isLoading)) + return ; + + return ( +
+ + +
+ ); +} + +const InboxSidebar = memo(function InboxSidebar({ + notifications, + selectedId, + onSelect, + filter, + onFilterChange, + scope, + isRefetching, +}: { + notifications: NotificationItem[]; + selectedId: string | null; + onSelect: (notification: NotificationItem) => void; + filter: InboxFilter; + onFilterChange: (filter: InboxFilter) => void; + scope: GitHubQueryScope; + isRefetching: boolean; +}) { + const queryClient = useQueryClient(); + const queryKey = githubQueryKeys.notifications.list(scope, { + all: filter === "all", + }); + const markAllRead = useMutation({ + mutationFn: () => markAllNotificationsRead(), + onMutate: () => { + const prev = queryClient.getQueryData(queryKey); + if (prev) { + queryClient.setQueryData(queryKey, { + notifications: prev.notifications.map((n) => ({ + ...n, + unread: false, + })), + }); + } + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev); + }, + }); + + const markAllDone = useMutation({ + mutationFn: async () => { + const readNotifications = notifications.filter((n) => !n.unread); + await Promise.all( + readNotifications.map((n) => + markNotificationDone({ data: { threadId: n.id } }), + ), + ); + }, + onMutate: () => { + const prev = queryClient.getQueryData(queryKey); + if (prev) { + queryClient.setQueryData(queryKey, { + notifications: prev.notifications.filter((n) => n.unread), + }); + } + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev); + }, + }); + + const hasUnread = notifications.some((n) => n.unread); + + return ( + + ); +}); + +const reasonLabels: Record = { + assign: "Assigned", + author: "Author", + comment: "Comment", + ci_activity: "CI", + invitation: "Invited", + manual: "Subscribed", + mention: "Mentioned", + review_requested: "Review requested", + security_alert: "Security", + state_change: "State change", + subscribed: "Subscribed", + team_mention: "Team mention", +}; + +function getSubjectIcon(type: string, state: string | null) { + switch (type) { + case "PullRequest": + if (state === "merged") return GitMergeIcon; + if (state === "closed") return GitPullRequestClosedIcon; + return GitPullRequestIcon; + case "Issue": + return IssuesIcon; + case "Commit": + return GitMergeIcon; + case "Discussion": + return CommentIcon; + default: + return NotificationIcon; + } +} + +function getSubjectColor(type: string, state: string | null) { + switch (type) { + case "PullRequest": + if (state === "merged") return "text-purple-500"; + if (state === "closed") return "text-red-500"; + return "text-green-500"; + case "Issue": + if (state === "closed") return "text-red-500"; + return "text-green-500"; + case "Commit": + return "text-blue-500"; + default: + return "text-muted-foreground"; + } +} + +function extractSubjectNumber(url: string | null): number | null { + if (!url) return null; + const match = url.match(RE_PULLS_NUMBER); + return match ? Number.parseInt(match[1], 10) : null; +} + +const InboxRow = memo(function InboxRow({ + notification, + isSelected, + onSelect, + scope, + filter, +}: { + notification: NotificationItem; + isSelected: boolean; + onSelect: (notification: NotificationItem) => void; + scope: GitHubQueryScope; + filter: InboxFilter; +}) { + const queryClient = useQueryClient(); + const Icon = getSubjectIcon( + notification.subject.type, + notification.subjectState, + ); + const iconColor = getSubjectColor( + notification.subject.type, + notification.subjectState, + ); + const number = extractSubjectNumber(notification.subject.url); + + const queryKey = githubQueryKeys.notifications.list(scope, { + all: filter === "all", + }); + + const markDone = useMutation({ + mutationFn: () => + markNotificationDone({ data: { threadId: notification.id } }), + onMutate: () => { + const prev = queryClient.getQueryData(queryKey); + if (prev) { + queryClient.setQueryData(queryKey, { + notifications: prev.notifications.filter( + (n) => n.id !== notification.id, + ), + }); + } + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev); + }, + }); + + const markRead = useMutation({ + mutationFn: () => + markNotificationRead({ data: { threadId: notification.id } }), + onMutate: () => { + const prev = queryClient.getQueryData(queryKey); + if (prev) { + queryClient.setQueryData(queryKey, { + notifications: prev.notifications.map((n) => + n.id === notification.id ? { ...n, unread: false } : n, + ), + }); + } + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev); + }, + }); + + return ( + { + onSelect(notification); + if (notification.unread) { + markRead.mutate(); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(notification); + if (notification.unread) { + markRead.mutate(); + } + } + }} + className={cn( + "group flex w-full cursor-pointer items-start gap-3 border-b border-border/50 px-4 py-3 text-left transition-colors last:border-b-0", + "hover:[&:not(:has([data-action]:hover))]:bg-surface-1", + isSelected && "bg-surface-1", + )} + > +
+ +
+
+
+
+ + {notification.repository.fullName} + {number ? ` #${number}` : ""} + + {notification.unread && ( + + )} +
+

+ {notification.subject.title} +

+
+
+ + {reasonLabels[notification.reason] ?? notification.reason} + + · + + {formatRelativeTime(notification.updatedAt)} + +
+
+
+
+ +
+
+ + {notification.unread && ( + + )} +
+
+
+ ); +}); + +const InboxPreview = memo(function InboxPreview({ + notification, + userId, +}: { + notification: NotificationItem | null; + scope: GitHubQueryScope; + userId: string; +}) { + if (!notification) { + return ( +
+
+
+ +
+
+

Select a notification

+

+ Choose a notification from the list to preview +

+
+
+
+ ); + } + + const parsed = parseSubjectRef(notification); + const internalHref = buildInternalHref(notification); + + return ( +
+
+
+ + {notification.repository.owner.login} + {notification.repository.fullName} + + {parsed?.number ? #{parsed.number} : null} +
+ {internalHref && ( + + )} +
+
+ +
+
+ ); +}); + +type ParsedSubjectRef = { + type: "PullRequest" | "Issue"; + owner: string; + repo: string; + number: number; +}; + +function parseSubjectRef( + notification: NotificationItem, +): ParsedSubjectRef | null { + const url = notification.subject.url; + if (!url) return null; + const owner = notification.repository.owner.login; + const repo = notification.repository.name; + + const pullMatch = url.match(RE_API_PULLS); + if (pullMatch) { + return { + type: "PullRequest", + owner, + repo, + number: Number.parseInt(pullMatch[1], 10), + }; + } + + const issueMatch = url.match(RE_API_ISSUES); + if (issueMatch) { + return { + type: "Issue", + owner, + repo, + number: Number.parseInt(issueMatch[1], 10), + }; + } + + return null; +} + +function InboxPreviewContent({ + notification, + parsed, + userId, +}: { + notification: NotificationItem; + parsed: ParsedSubjectRef | null; + userId: string; +}) { + if (parsed?.type === "PullRequest") { + return ( + + ); + } + + if (parsed?.type === "Issue") { + return ( + + ); + } + + return ; +} + +function InboxPreviewFallback({ + notification, +}: { + notification: NotificationItem; +}) { + const Icon = getSubjectIcon( + notification.subject.type, + notification.subjectState, + ); + const iconColor = getSubjectColor( + notification.subject.type, + notification.subjectState, + ); + const ghUrl = buildGitHubUrl(notification); + + return ( +
+
+
+ +
+
+

+ {notification.subject.title} +

+
+ + {notification.subject.type} + + · + + {reasonLabels[notification.reason] ?? notification.reason} + + · + {formatRelativeTime(notification.updatedAt)} +
+
+
+ +
+
+ {notification.repository.owner.login} +
+ + {notification.repository.fullName} + + + {notification.repository.private + ? "Private repository" + : "Public repository"} + +
+
+
+ + {ghUrl && ( + + View on GitHub → + + )} +
+ ); +} + +function buildGitHubUrl(notification: NotificationItem): string | null { + const { subject, repository } = notification; + const base = `https://github.com/${repository.fullName}`; + + if (!subject.url) return base; + + // subject.url is an API URL like https://api.github.com/repos/owner/repo/pulls/123 + const pullMatch = subject.url.match(RE_API_PULLS); + if (pullMatch) return `${base}/pull/${pullMatch[1]}`; + + const issueMatch = subject.url.match(RE_API_ISSUES); + if (issueMatch) return `${base}/issues/${issueMatch[1]}`; + + const commitMatch = subject.url.match(RE_API_COMMITS); + if (commitMatch) return `${base}/commit/${commitMatch[1]}`; + + const releaseMatch = subject.url.match(RE_API_RELEASES); + if (releaseMatch) return `${base}/releases`; + + return base; +} + +function buildInternalHref(notification: NotificationItem): string | null { + const { subject, repository } = notification; + const owner = repository.owner.login; + const repo = repository.name; + + if (!subject.url) return null; + + const pullMatch = subject.url.match(RE_API_PULLS); + if (pullMatch) return `/${owner}/${repo}/pull/${pullMatch[1]}`; + + const issueMatch = subject.url.match(RE_API_ISSUES); + if (issueMatch) return `/${owner}/${repo}/issues/${issueMatch[1]}`; + + return null; +} diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx index 517f658..7e714b4 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx @@ -25,8 +25,34 @@ const routeApi = getRouteApi("/_protected/$owner/$repo/issues/$issueId"); export function IssueDetailPage() { const { user } = routeApi.useRouteContext(); const { owner, repo, issueId } = routeApi.useParams(); - const issueNumber = Number(issueId); - const scope = useMemo(() => ({ userId: user.id }), [user.id]); + + return ( + + ); +} + +export type IssueDetailContentProps = { + owner: string; + repo: string; + issueNumber: number; + userId: string; + registerTab?: boolean; +}; + +export function IssueDetailContent({ + owner, + repo, + issueNumber, + userId, + registerTab = false, +}: IssueDetailContentProps) { + const scope = useMemo(() => ({ userId }), [userId]); const input = useMemo( () => ({ owner, repo, issueNumber }), [owner, repo, issueNumber], @@ -63,12 +89,12 @@ export function IssueDetailPage() { const eventPagination = pageQuery.data?.eventPagination; useRegisterTab( - issue + registerTab && issue ? { type: "issue", title: issue.title, number: issue.number, - url: `/${owner}/${repo}/issues/${issueId}`, + url: `/${owner}/${repo}/issues/${issueNumber}`, repo: `${owner}/${repo}`, iconColor: getIssueStateConfig(issue).color, } diff --git a/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx b/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx index b9edb3a..6009653 100644 --- a/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx @@ -1,6 +1,7 @@ import { GitPullRequestIcon, HomeIcon, + InboxIcon, IssuesIcon, MoonIcon, ReviewsIcon, @@ -69,6 +70,7 @@ export function DashboardMobileNav({ const navItems: MobileNavItem[] = [ { to: "/", label: "Overview", icon: HomeIcon }, + { to: "/inbox", label: "Inbox", icon: InboxIcon }, { to: "/pulls", label: "Pulls", diff --git a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx index 9a14c26..1baa9b7 100644 --- a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx @@ -3,6 +3,7 @@ import { ExternalLinkIcon, GitPullRequestIcon, HomeIcon, + InboxIcon, IssuesIcon, LogOutIcon, MoreHorizontalIcon, @@ -28,7 +29,10 @@ import { Link, useRouter } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState } from "react"; import { DashboardTabs } from "#/components/layouts/dashboard-tabs"; import { signOutToLogin } from "#/lib/auth-actions"; -import { githubViewerQueryOptions } from "#/lib/github.query"; +import { + githubNotificationsQueryOptions, + githubViewerQueryOptions, +} from "#/lib/github.query"; import { useGlobalShortcuts } from "#/lib/shortcuts"; import { type Tab, useTabs } from "#/lib/tab-store"; import { useHasMounted } from "#/lib/use-has-mounted"; @@ -53,9 +57,16 @@ type NavItem = { label: string; icon: typeof HomeIcon; count?: number; + dot?: boolean; }; -const primaryNavRoutes = ["/", "/pulls", "/issues", "/reviews"] as const; +const primaryNavRoutes = [ + "/", + "/inbox", + "/pulls", + "/issues", + "/reviews", +] as const; const MAX_TAB_SHORTCUTS = 9; export function DashboardTopbar({ @@ -71,6 +82,11 @@ export function DashboardTopbar({ enabled: hasMounted, }); const viewerLogin = viewerQuery.data?.login; + const notificationsQuery = useQuery({ + ...githubNotificationsQueryOptions({ userId: user.id }, { all: false }), + enabled: hasMounted, + }); + const hasUnread = (notificationsQuery.data?.notifications?.length ?? 0) > 0; // Store router in a ref — only used imperatively (navigate, preload), // never read during render, so we avoid subscribing to state changes. const router = useRouter(); @@ -88,6 +104,7 @@ export function DashboardTopbar({ const navItems = useMemo( () => [ { to: "/", label: "Overview", icon: HomeIcon }, + { to: "/inbox", label: "Inbox", icon: InboxIcon, dot: hasUnread }, { to: "/pulls", label: "Pull Requests", @@ -107,7 +124,7 @@ export function DashboardTopbar({ count: counts.reviews, }, ], - [counts.pulls, counts.issues, counts.reviews], + [counts.pulls, counts.issues, counts.reviews, hasUnread], ); useEffect(() => { @@ -284,7 +301,9 @@ export function DashboardTopbar({ > {item.label} - {typeof item.count === "number" ? ( + {item.dot ? ( + + ) : typeof item.count === "number" ? ( )} - {pr.headRef && pr.baseRef && ( + {pr.headRef && pr.baseRef && pr.state !== "closed" && (
{pr.headRef} diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx index 1d00804..4ec975c 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx @@ -63,17 +63,23 @@ export function PullDetailHeader({ {pr.author.login} - wants to merge into - - from - + {(pr.isMerged || pr.state !== "closed") && ( + <> + + {pr.isMerged ? "merged into" : "wants to merge into"} + + + from + + + )} )}
diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx index f185516..1bb9876 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx @@ -28,8 +28,34 @@ const routeApi = getRouteApi("/_protected/$owner/$repo/pull/$pullId"); export function PullDetailPage() { const { user } = routeApi.useRouteContext(); const { owner, repo, pullId } = routeApi.useParams(); - const pullNumber = Number(pullId); - const scope = useMemo(() => ({ userId: user.id }), [user.id]); + + return ( + + ); +} + +export type PullDetailContentProps = { + owner: string; + repo: string; + pullNumber: number; + userId: string; + registerTab?: boolean; +}; + +export function PullDetailContent({ + owner, + repo, + pullNumber, + userId, + registerTab = false, +}: PullDetailContentProps) { + const scope = useMemo(() => ({ userId }), [userId]); const input = useMemo( () => ({ owner, repo, pullNumber }), [owner, repo, pullNumber], @@ -92,12 +118,12 @@ export function PullDetailPage() { const viewer = viewerQuery.data ?? null; useRegisterTab( - pr + registerTab && pr ? { type: "pull", title: pr.title, number: pr.number, - url: `/${owner}/${repo}/pull/${pullId}`, + url: `/${owner}/${repo}/pull/${pullNumber}`, repo: `${owner}/${repo}`, iconColor: getPrStateConfig(pr).color, } @@ -114,7 +140,7 @@ export function PullDetailPage() { diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index f8928db..3c6cc0a 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -18,6 +18,9 @@ import type { IssueSummary, MyIssuesResult, MyPullsResult, + NotificationItem, + NotificationParticipant, + NotificationsResult, OrgTeam, PinnedRepo, PullComment, @@ -6917,6 +6920,195 @@ export const getRepoContributors = createServerFn({ method: "GET" }) }); }); +// ── Notifications ────────────────────────────────────────────────────── + +type GetNotificationsInput = { + all?: boolean; + participating?: boolean; +}; + +export const getNotifications = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return { notifications: [] }; + } + + const response = + await context.octokit.rest.activity.listNotificationsForAuthenticatedUser( + { + all: data.all ?? false, + participating: data.participating ?? false, + per_page: 50, + }, + ); + + // Batch-fetch participants for PR/Issue notifications in parallel + const participantMap = new Map(); + const stateMap = new Map(); + const fetchable = response.data.filter( + (n) => + n.subject.url && + (n.subject.type === "PullRequest" || n.subject.type === "Issue"), + ); + + await Promise.allSettled( + fetchable.map(async (n) => { + try { + const seen = new Set(); + const participants: NotificationParticipant[] = []; + const add = (login: string, avatarUrl: string) => { + if (seen.has(login)) return; + seen.add(login); + participants.push({ login, avatarUrl }); + }; + + // Fetch subject detail + comments (and reviews for PRs) in parallel + const subjectUrl = n.subject.url!; + const commentsUrl = `${subjectUrl}/comments`; + const isPR = n.subject.type === "PullRequest"; + const reviewsUrl = isPR ? `${subjectUrl}/reviews` : null; + + const [subjectRes, commentsRes, reviewsRes] = await Promise.all([ + context.octokit.request("GET {url}", { url: subjectUrl }), + context.octokit + .request("GET {url}", { url: commentsUrl, per_page: 100 }) + .catch(() => null), + reviewsUrl + ? context.octokit + .request("GET {url}", { url: reviewsUrl, per_page: 100 }) + .catch(() => null) + : null, + ]); + + const d = subjectRes.data as { + user?: { login: string; avatar_url: string }; + assignees?: Array<{ login: string; avatar_url: string }>; + requested_reviewers?: Array<{ login: string; avatar_url: string }>; + state?: string; + merged?: boolean; + }; + + // Extract subject state (open/closed/merged) + if (d.state) { + const state = d.merged + ? "merged" + : d.state === "closed" + ? "closed" + : "open"; + stateMap.set(n.id, state); + } + + if (d.user) add(d.user.login, d.user.avatar_url); + for (const a of d.assignees ?? []) add(a.login, a.avatar_url); + for (const r of d.requested_reviewers ?? []) + add(r.login, r.avatar_url); + + // Add commenters + if (commentsRes?.data && Array.isArray(commentsRes.data)) { + for (const c of commentsRes.data as Array<{ + user?: { login: string; avatar_url: string }; + }>) { + if (c.user) add(c.user.login, c.user.avatar_url); + } + } + + // Add reviewers (PRs only) + if (reviewsRes?.data && Array.isArray(reviewsRes.data)) { + for (const r of reviewsRes.data as Array<{ + user?: { login: string; avatar_url: string }; + }>) { + if (r.user) add(r.user.login, r.user.avatar_url); + } + } + + participantMap.set(n.id, participants); + } catch { + // Silently skip — participant data is best-effort + } + }), + ); + + const notifications: NotificationItem[] = response.data.map((n) => ({ + id: n.id, + unread: n.unread, + reason: n.reason as NotificationItem["reason"], + subject: { + title: n.subject.title, + url: n.subject.url ?? null, + latestCommentUrl: n.subject.latest_comment_url ?? null, + type: n.subject.type as NotificationItem["subject"]["type"], + }, + repository: { + id: n.repository.id, + name: n.repository.name, + fullName: n.repository.full_name, + owner: { + login: n.repository.owner.login, + avatarUrl: n.repository.owner.avatar_url, + url: n.repository.owner.html_url ?? n.repository.owner.url, + type: n.repository.owner.type ?? "User", + }, + private: n.repository.private, + }, + participants: participantMap.get(n.id) ?? [], + subjectState: stateMap.get(n.id) ?? null, + updatedAt: n.updated_at, + lastReadAt: n.last_read_at ?? null, + url: n.url, + })); + + return { notifications }; + }); + +type MarkNotificationReadInput = { threadId: string }; + +export const markNotificationRead = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise<{ ok: boolean }> => { + const context = await getGitHubContext(); + if (!context) { + return { ok: false }; + } + + await context.octokit.rest.activity.markThreadAsRead({ + thread_id: Number.parseInt(data.threadId, 10), + }); + + return { ok: true }; + }); + +export const markNotificationDone = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise<{ ok: boolean }> => { + const context = await getGitHubContext(); + if (!context) { + return { ok: false }; + } + + await context.octokit.rest.activity.markThreadAsDone({ + thread_id: Number.parseInt(data.threadId, 10), + }); + + return { ok: true }; + }); + +export const markAllNotificationsRead = createServerFn({ + method: "POST", +}).handler(async (): Promise<{ ok: boolean }> => { + const context = await getGitHubContext(); + if (!context) { + return { ok: false }; + } + + await context.octokit.rest.activity.markNotificationsAsRead({ + last_read_at: new Date().toISOString(), + }); + + return { ok: true }; +}); + type RevalidationSignalTimestampsInput = { signalKeys: string[] }; export const getRevalidationSignalTimestamps = createServerFn({ diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index 82b3a36..28993e8 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -10,6 +10,7 @@ import { getIssuesFromUser, getMyIssues, getMyPulls, + getNotifications, getOrgTeams, getPullComments, getPullFileSummaries, @@ -222,6 +223,12 @@ export const githubQueryKeys = { comments: (scope: GitHubQueryScope, input: IssueFromRepoQueryInput) => ["github", scope.userId, "issues", "comments", input] as const, }, + notifications: { + list: ( + scope: GitHubQueryScope, + input: { all?: boolean; participating?: boolean }, + ) => ["github", scope.userId, "notifications", "list", input] as const, + }, }; export function githubViewerQueryOptions(scope: GitHubQueryScope) { @@ -673,3 +680,16 @@ export function githubRepoDiscussionsQueryOptions( meta: persistedMeta, }); } + +export function githubNotificationsQueryOptions( + scope: GitHubQueryScope, + input: { all?: boolean; participating?: boolean } = {}, +) { + return queryOptions({ + queryKey: githubQueryKeys.notifications.list(scope, input), + queryFn: () => getNotifications({ data: input }), + staleTime: githubCachePolicy.list.staleTimeMs, + gcTime: githubCachePolicy.list.gcTimeMs, + meta: persistedMeta, + }); +} diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 199d0a4..da7a66c 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -537,3 +537,61 @@ export type DiscussionsResult = { discussions: DiscussionSummary[]; totalCount: number; }; + +export type NotificationSubject = { + title: string; + url: string | null; + latestCommentUrl: string | null; + type: + | "CheckSuite" + | "Commit" + | "Discussion" + | "Issue" + | "PullRequest" + | "Release" + | "RepositoryVulnerabilityAlert" + | "RepositoryDependabotAlertsThread" + | "RepositoryAdvisory" + | (string & {}); +}; + +export type NotificationParticipant = { + login: string; + avatarUrl: string; +}; + +export type NotificationItem = { + id: string; + unread: boolean; + reason: + | "assign" + | "author" + | "comment" + | "ci_activity" + | "invitation" + | "manual" + | "mention" + | "review_requested" + | "security_alert" + | "state_change" + | "subscribed" + | "team_mention" + | (string & {}); + subject: NotificationSubject; + repository: { + id: number; + name: string; + fullName: string; + owner: GitHubActor; + private: boolean; + }; + participants: NotificationParticipant[]; + subjectState: "open" | "closed" | "merged" | null; + updatedAt: string; + lastReadAt: string | null; + url: string; +}; + +export type NotificationsResult = { + notifications: NotificationItem[]; +}; diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 5014470..c3ed90b 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as ProtectedSettingsRouteImport } from './routes/_protected/setti import { Route as ProtectedReviewsRouteImport } from './routes/_protected/reviews' import { Route as ProtectedPullsRouteImport } from './routes/_protected/pulls' import { Route as ProtectedIssuesRouteImport } from './routes/_protected/issues' +import { Route as ProtectedInboxRouteImport } from './routes/_protected/inbox' import { Route as ProtectedSettingsIndexRouteImport } from './routes/_protected/settings/index' import { Route as ProtectedOwnerIndexRouteImport } from './routes/_protected/$owner/index' import { Route as ApiWebhooksGithubRouteImport } from './routes/api/webhooks/github' @@ -89,6 +90,11 @@ const ProtectedIssuesRoute = ProtectedIssuesRouteImport.update({ path: '/issues', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedInboxRoute = ProtectedInboxRouteImport.update({ + id: '/inbox', + path: '/inbox', + getParentRoute: () => ProtectedRoute, +} as any) const ProtectedSettingsIndexRoute = ProtectedSettingsIndexRouteImport.update({ id: '/', path: '/', @@ -173,6 +179,7 @@ export interface FileRoutesByFullPath { '/privacy': typeof PrivacyRoute '/setup': typeof SetupRoute '/terms': typeof TermsRoute + '/inbox': typeof ProtectedInboxRoute '/issues': typeof ProtectedIssuesRoute '/pulls': typeof ProtectedPullsRoute '/reviews': typeof ProtectedReviewsRoute @@ -198,6 +205,7 @@ export interface FileRoutesByTo { '/privacy': typeof PrivacyRoute '/setup': typeof SetupRoute '/terms': typeof TermsRoute + '/inbox': typeof ProtectedInboxRoute '/issues': typeof ProtectedIssuesRoute '/pulls': typeof ProtectedPullsRoute '/reviews': typeof ProtectedReviewsRoute @@ -225,6 +233,7 @@ export interface FileRoutesById { '/privacy': typeof PrivacyRoute '/setup': typeof SetupRoute '/terms': typeof TermsRoute + '/_protected/inbox': typeof ProtectedInboxRoute '/_protected/issues': typeof ProtectedIssuesRoute '/_protected/pulls': typeof ProtectedPullsRoute '/_protected/reviews': typeof ProtectedReviewsRoute @@ -254,6 +263,7 @@ export interface FileRouteTypes { | '/privacy' | '/setup' | '/terms' + | '/inbox' | '/issues' | '/pulls' | '/reviews' @@ -279,6 +289,7 @@ export interface FileRouteTypes { | '/privacy' | '/setup' | '/terms' + | '/inbox' | '/issues' | '/pulls' | '/reviews' @@ -305,6 +316,7 @@ export interface FileRouteTypes { | '/privacy' | '/setup' | '/terms' + | '/_protected/inbox' | '/_protected/issues' | '/_protected/pulls' | '/_protected/reviews' @@ -418,6 +430,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedIssuesRouteImport parentRoute: typeof ProtectedRoute } + '/_protected/inbox': { + id: '/_protected/inbox' + path: '/inbox' + fullPath: '/inbox' + preLoaderRoute: typeof ProtectedInboxRouteImport + parentRoute: typeof ProtectedRoute + } '/_protected/settings/': { id: '/_protected/settings/' path: '/' @@ -533,6 +552,7 @@ const ProtectedSettingsRouteWithChildren = ProtectedSettingsRoute._addFileChildren(ProtectedSettingsRouteChildren) interface ProtectedRouteChildren { + ProtectedInboxRoute: typeof ProtectedInboxRoute ProtectedIssuesRoute: typeof ProtectedIssuesRoute ProtectedPullsRoute: typeof ProtectedPullsRoute ProtectedReviewsRoute: typeof ProtectedReviewsRoute @@ -549,6 +569,7 @@ interface ProtectedRouteChildren { } const ProtectedRouteChildren: ProtectedRouteChildren = { + ProtectedInboxRoute: ProtectedInboxRoute, ProtectedIssuesRoute: ProtectedIssuesRoute, ProtectedPullsRoute: ProtectedPullsRoute, ProtectedReviewsRoute: ProtectedReviewsRoute, diff --git a/apps/dashboard/src/routes/_protected/inbox.tsx b/apps/dashboard/src/routes/_protected/inbox.tsx new file mode 100644 index 0000000..f35f6f5 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/inbox.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { InboxPage } from "#/components/inbox/inbox-page"; +import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; +import { githubNotificationsQueryOptions } from "#/lib/github.query"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; + +export const Route = createFileRoute("/_protected/inbox")({ + ssr: false, + loader: async ({ context }) => { + const scope = { userId: context.user.id }; + void context.queryClient.prefetchQuery( + githubNotificationsQueryOptions(scope), + ); + }, + pendingComponent: DashboardContentLoading, + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("Inbox"), + description: "GitHub notifications inbox", + robots: "noindex", + }), + component: InboxPage, +}); diff --git a/packages/icons/src/archive-down-icon.tsx b/packages/icons/src/archive-down-icon.tsx new file mode 100644 index 0000000..20dd0bf --- /dev/null +++ b/packages/icons/src/archive-down-icon.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from "react"; + +export function ArchiveDownIcon( + props: SVGProps & { size?: number } +) { + const { size = 24, width, height, ...rest } = props; + return ( + + + + + ); +} diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 3fc4608..97e4d8b 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -68,6 +68,7 @@ export { ViewIcon, WifiDisconnected01Icon as WifiOffIcon, } from "@hugeicons/react"; +export { ArchiveDownIcon } from "./archive-down-icon"; export { GitHubLogo, GitHubWordmarkLogo, XLogo } from "./brand-logos"; export { PenIcon } from "./pen-icon"; export { SeparatorHorizontalIcon } from "./separator-horizontal-icon";