CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
notifications.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 3ef4c9d | 1 | /** |
| 59b6fb2 | 2 | * Notification routes — bell icon, list, mark read, clear. |
| 3ef4c9d | 3 | */ |
| 4 | ||
| 5 | import { Hono } from "hono"; | |
| 59b6fb2 | 6 | import { eq, and, desc, sql } from "drizzle-orm"; |
| 3ef4c9d | 7 | import { db } from "../db"; |
| 59b6fb2 | 8 | import { notifications } from "../db/schema-extensions"; |
| 9 | import { users, repositories } from "../db/schema"; | |
| 3ef4c9d | 10 | import { Layout } from "../views/layout"; |
| 59b6fb2 | 11 | import { softAuth, requireAuth } from "../middleware/auth"; |
| 3ef4c9d | 12 | import type { AuthEnv } from "../middleware/auth"; |
| 3e8f8e8 | 13 | import { |
| 14 | Container, | |
| 15 | PageHeader, | |
| 16 | Flex, | |
| 17 | FilterTabs, | |
| 18 | EmptyState, | |
| 19 | List, | |
| 20 | ListItem, | |
| 21 | Form, | |
| 22 | Button, | |
| 23 | Text, | |
| 24 | formatRelative, | |
| 25 | } from "../views/ui"; | |
| 59b6fb2 | 26 | |
| 27 | const notificationRoutes = new Hono<AuthEnv>(); | |
| 28 | ||
| 29 | // Notification list page | |
| 30 | notificationRoutes.get("/notifications", softAuth, requireAuth, async (c) => { | |
| 31 | const user = c.get("user")!; | |
| 32 | const filter = c.req.query("filter") || "unread"; | |
| 3e8f8e8 | 33 | const csrfToken = (c as any).get("csrfToken") || ""; |
| 59b6fb2 | 34 | |
| 35 | const query = db | |
| 36 | .select() | |
| 37 | .from(notifications) | |
| 38 | .where( | |
| 39 | filter === "all" | |
| 40 | ? eq(notifications.userId, user.id) | |
| 41 | : and(eq(notifications.userId, user.id), eq(notifications.isRead, false)) | |
| 42 | ) | |
| 43 | .orderBy(desc(notifications.createdAt)) | |
| 44 | .limit(50); | |
| 45 | ||
| 46 | let items: any[] = []; | |
| 3ef4c9d | 47 | try { |
| 59b6fb2 | 48 | items = await query; |
| 3ef4c9d | 49 | } catch { |
| 59b6fb2 | 50 | // Table may not exist yet |
| 3ef4c9d | 51 | } |
| 52 | ||
| 59b6fb2 | 53 | let unreadCount = 0; |
| 3ef4c9d | 54 | try { |
| 59b6fb2 | 55 | const [result] = await db |
| 56 | .select({ count: sql<number>`count(*)` }) | |
| 3ef4c9d | 57 | .from(notifications) |
| 59b6fb2 | 58 | .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false))); |
| 59 | unreadCount = result?.count ?? 0; | |
| 60 | } catch { | |
| 61 | // Table may not exist yet | |
| 3ef4c9d | 62 | } |
| 63 | ||
| 3e8f8e8 | 64 | const markAllReadAction = unreadCount > 0 ? ( |
| 65 | <Form action="/notifications/read-all" csrfToken={csrfToken}> | |
| 66 | <Button size="sm" type="submit">Mark all read</Button> | |
| 67 | </Form> | |
| 68 | ) : null; | |
| 3ef4c9d | 69 | |
| 70 | return c.html( | |
| 71 | <Layout title="Notifications" user={user}> | |
| 72 | <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px"> | |
| 73 | <h2>Notifications</h2> | |
| 74 | {unreadCount > 0 && ( | |
| e7e240e | 75 | <form method="post" action="/notifications/read-all"> |
| 3ef4c9d | 76 | <button type="submit" class="btn btn-sm"> |
| 77 | Mark all as read | |
| 78 | </button> | |
| 79 | </form> | |
| 80 | )} | |
| 81 | </div> | |
| 82 | ||
| 83 | <div class="issue-tabs" style="margin-bottom: 16px"> | |
| 84 | <a href="/notifications" class={filter === "all" ? "active" : ""}> | |
| 85 | All | |
| 86 | </a> | |
| 87 | <a | |
| 88 | href="/notifications?filter=unread" | |
| 89 | class={filter === "unread" ? "active" : ""} | |
| 90 | > | |
| 91 | Unread | |
| 92 | </a> | |
| 93 | <a | |
| 94 | href="/notifications?filter=mentions" | |
| 95 | class={filter === "mentions" ? "active" : ""} | |
| 96 | > | |
| 97 | Mentions | |
| 98 | </a> | |
| 99 | </div> | |
| 100 | ||
| 101 | {rows.length === 0 ? ( | |
| 102 | <div class="empty-state"> | |
| 103 | <h2>Inbox zero</h2> | |
| 104 | <p>You're all caught up.</p> | |
| 105 | </div> | |
| 59b6fb2 | 106 | |
| 107 | {items.length === 0 ? ( | |
| 3e8f8e8 | 108 | <EmptyState title="All caught up"> |
| 59b6fb2 | 109 | <p>No {filter === "unread" ? "unread " : ""}notifications.</p> |
| 3e8f8e8 | 110 | </EmptyState> |
| 59b6fb2 | 111 | ) : ( |
| 3e8f8e8 | 112 | <List> |
| 59b6fb2 | 113 | {items.map((n: any) => ( |
| 3e8f8e8 | 114 | <ListItem style={n.isRead ? "opacity:0.6" : ""}> |
| 59b6fb2 | 115 | <div style="font-size:18px;padding-top:2px"> |
| 116 | {n.type === "issue_comment" ? "\u{1F4AC}" : | |
| 117 | n.type === "pr_review" ? "\u{1F50D}" : | |
| 118 | n.type === "mention" ? "\u{1F4E3}" : | |
| 119 | n.type === "star" ? "\u2B50" : | |
| 120 | n.type === "ci_status" ? "\u2699\uFE0F" : "\u{1F514}"} | |
| 121 | </div> | |
| 3e8f8e8 | 122 | <Flex direction="column" style="flex:1"> |
| 123 | <Text size={14} weight={500}> | |
| 59b6fb2 | 124 | {n.url ? <a href={n.url} style="color:var(--text)">{n.title}</a> : n.title} |
| 3e8f8e8 | 125 | </Text> |
| 3ef4c9d | 126 | {n.body && ( |
| 3e8f8e8 | 127 | <Text size={13} muted style="margin-top:2px"> |
| 59b6fb2 | 128 | {n.body.length > 120 ? n.body.slice(0, 120) + "..." : n.body} |
| 3e8f8e8 | 129 | </Text> |
| 3ef4c9d | 130 | )} |
| 131 | <div class="notification-meta"> | |
| 132 | {n.repoOwner && n.repoName && ( | |
| 133 | <> | |
| 134 | <a href={`/${n.repoOwner}/${n.repoName}`}> | |
| 135 | {n.repoOwner}/{n.repoName} | |
| 136 | </a> | |
| 137 | <span> · </span> | |
| 138 | </> | |
| 139 | )} | |
| 140 | <span>{formatRelative(n.createdAt)}</span> | |
| 141 | </div> | |
| 142 | </div> | |
| 143 | <div class="notification-actions"> | |
| 144 | {unread && ( | |
| e7e240e | 145 | <form method="post" action={`/notifications/${n.id}/read`}> |
| 3ef4c9d | 146 | <button |
| 147 | type="submit" | |
| 148 | class="btn btn-sm" | |
| 149 | title="Mark as read" | |
| 150 | > | |
| 151 | {"\u2713"} | |
| 152 | </button> | |
| 153 | </form> | |
| 154 | )} | |
| e7e240e | 155 | <form method="post" action={`/notifications/${n.id}/delete`}> |
| 3ef4c9d | 156 | <button |
| 157 | type="submit" | |
| 158 | class="btn btn-sm" | |
| 159 | title="Dismiss" | |
| 160 | > | |
| 161 | {"\u00D7"} | |
| 162 | </button> | |
| 163 | </form> | |
| 164 | </div> | |
| 165 | </div> | |
| 166 | ); | |
| 167 | })} | |
| 168 | </div> | |
| 169 | )} | |
| 170 | </Layout> | |
| 171 | ); | |
| 172 | }); | |
| 173 | ||
| 59b6fb2 | 174 | // Mark single notification as read |
| 175 | notificationRoutes.post("/notifications/:id/read", softAuth, requireAuth, async (c) => { | |
| 3ef4c9d | 176 | const user = c.get("user")!; |
| 177 | const id = c.req.param("id"); | |
| 59b6fb2 | 178 | |
| 3ef4c9d | 179 | try { |
| 180 | await db | |
| 181 | .update(notifications) | |
| 59b6fb2 | 182 | .set({ isRead: true }) |
| 3ef4c9d | 183 | .where(and(eq(notifications.id, id), eq(notifications.userId, user.id))); |
| 59b6fb2 | 184 | } catch { |
| 185 | // Table may not exist | |
| 3ef4c9d | 186 | } |
| 59b6fb2 | 187 | |
| 3ef4c9d | 188 | return c.redirect("/notifications"); |
| 189 | }); | |
| 190 | ||
| 59b6fb2 | 191 | // Mark all as read |
| 192 | notificationRoutes.post("/notifications/read-all", softAuth, requireAuth, async (c) => { | |
| 3ef4c9d | 193 | const user = c.get("user")!; |
| 59b6fb2 | 194 | |
| 3ef4c9d | 195 | try { |
| 196 | await db | |
| 197 | .update(notifications) | |
| 59b6fb2 | 198 | .set({ isRead: true }) |
| 199 | .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false))); | |
| 200 | } catch { | |
| 201 | // Table may not exist | |
| 3ef4c9d | 202 | } |
| 203 | ||
| 59b6fb2 | 204 | return c.redirect("/notifications"); |
| 3ef4c9d | 205 | }); |
| 206 | ||
| 59b6fb2 | 207 | // API: Get unread count (for bell icon polling) |
| 208 | notificationRoutes.get("/api/notifications/count", softAuth, async (c) => { | |
| 209 | const user = c.get("user"); | |
| 210 | if (!user) return c.json({ count: 0 }); | |
| 3ef4c9d | 211 | |
| 212 | try { | |
| 59b6fb2 | 213 | const [result] = await db |
| 214 | .select({ count: sql<number>`count(*)` }) | |
| 3ef4c9d | 215 | .from(notifications) |
| 59b6fb2 | 216 | .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false))); |
| 217 | return c.json({ count: result?.count ?? 0 }); | |
| 3ef4c9d | 218 | } catch { |
| 59b6fb2 | 219 | return c.json({ count: 0 }); |
| 3ef4c9d | 220 | } |
| 221 | }); | |
| 222 | ||
| 59b6fb2 | 223 | export default notificationRoutes; |