Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions client/app/components/HeaderNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,19 @@
</HeadlessMenu>

<!-- View Notifications -->
<button
<Anchor
v-if="userStore.authenticated"
type="button"
class="-m-2.5 mx-2 text-gray-400 dark:text-neutral-400 hover:text-gray-500 dark:hover:text-violet-400">
<span class="sr-only">View notifications</span>
href="/notifications"
class="relative -m-2.5 mx-2 text-gray-400 dark:text-neutral-400 hover:text-gray-500 dark:hover:text-violet-400">
<span class="sr-only"
>View notifications{{ unreadCount > 0 ? ` (${unreadCount} new)` : '' }}</span
>
<Icon name="solar:bell-bold-duotone" size="1.25em" aria-hidden="true" />
</button>
<span
v-if="unreadCount > 0"
class="absolute -top-0.5 -right-0.5 block h-2 w-2 rounded-full bg-red-500 ring-2 ring-white dark:ring-gray-800"
aria-hidden="true" />
</Anchor>

<!-- Separator -->
<div
Expand Down Expand Up @@ -247,6 +253,8 @@ const siteStore = useSiteStore()
const userStore = useUserStore()
const datatrackerLinks = useDatatrackerLinks()

const { unreadCount } = useNotifications()

// DATA

const userNavigation = [{ name: 'Your profile', href: datatrackerLinks.profile, external: true }]
Expand Down
53 changes: 53 additions & 0 deletions client/app/composables/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ref, onBeforeMount, onUnmounted, readonly } from 'vue'
import type { PurpleApi } from '~/purple_client'
import { useUserStore } from '~/stores/user'

// Shared singleton: one poll drives the header bell no matter how many
// components read the count (mirrors the useCurrentTime pattern).
const POLL_MS = 60_000
const unreadCount = ref(0)
let interval: ReturnType<typeof setInterval> | null = null
let instanceCount = 0
let api: PurpleApi | null = null

const poll = async () => {
if (!api) return
try {
const { count } = await api.notificationsUnreadCount()
unreadCount.value = count
} catch {
// transient network/auth errors: keep the last known count
}
}

export const useNotifications = () => {
api = useApi()
const userStore = useUserStore()

onBeforeMount(() => {
instanceCount++
if (userStore.authenticated) void poll()
if (interval === null) {
interval = setInterval(poll, POLL_MS)
}
})
onUnmounted(() => {
instanceCount--
if (instanceCount <= 0 && interval !== null) {
clearInterval(interval)
interval = null
}
})

const markAllRead = async () => {
if (!api) return
await api.notificationsMarkRead()
unreadCount.value = 0
}

return {
unreadCount: readonly(unreadCount),
refresh: poll,
markAllRead
}
}
84 changes: 84 additions & 0 deletions client/app/pages/notifications.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<template>
<div class="container mx-auto p-6 max-w-3xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Notifications</h1>
<RefreshButton :pending="pending" @refresh="refresh" />
</div>

<div v-if="pending && items.length === 0" class="py-8 text-center text-gray-500">
Loading notifications...
</div>

<ErrorAlert v-else-if="error" title="Error loading notifications">
{{ error }}
</ErrorAlert>

<div v-else-if="items.length === 0" class="py-12 text-center text-gray-500">
Nothing here yet.
</div>

<ul v-else class="divide-y divide-gray-100 dark:divide-gray-700">
<li v-for="n in items" :key="n.id">
<Anchor
:href="`/docs/${n.draftName}/assignments`"
class="flex items-start gap-3 py-3 px-2 -mx-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
:class="n.unread ? 'bg-violet-50/60 dark:bg-violet-900/20' : ''">
<span
class="mt-2 h-2 w-2 shrink-0 rounded-full"
:class="n.unread ? 'bg-violet-600' : 'bg-transparent'"
aria-hidden="true" />
<Icon
:name="
n.eventType === 'blocked'
? 'solar:lock-keyhole-bold-duotone'
: 'solar:lock-keyhole-unlocked-bold-duotone'
"
:class="n.eventType === 'blocked' ? 'text-red-500' : 'text-green-600'"
class="mt-0.5 h-5 w-5 shrink-0" />
<div class="min-w-0 flex-1">
<div class="text-sm text-gray-900 dark:text-gray-100">
<span class="font-semibold">{{ n.draftName }}</span>
was {{ n.eventType === 'blocked' ? 'blocked' : 'unblocked' }}
</div>
<div
v-if="n.eventType === 'blocked' && n.reasons?.length"
class="mt-1 flex flex-wrap gap-1">
<BaseBadge v-for="reason in n.reasons" :key="reason" :label="reason" />
</div>
</div>
<time
class="shrink-0 text-xs text-gray-400 dark:text-gray-500"
:title="n.created?.toISOString()">
{{ relativeTime(n.created) }}
</time>
</Anchor>
</li>
</ul>
</div>
</template>

<script setup lang="ts">
import { DateTime } from 'luxon'

const api = useApi()
const { markAllRead } = useNotifications()

const { data, pending, error, refresh } = await useAsyncData(
'notifications',
() => api.notificationsList(),
{
server: false,
lazy: true
}
)

const items = computed(() => data.value?.results ?? [])

const relativeTime = (d?: Date) => (d ? (DateTime.fromJSDate(d).toRelative() ?? '') : '')

// Visiting the page clears the bell dot; the unread highlights above stay for
// this render (they came from the list fetched before the watermark advanced).
onMounted(markAllRead)

useHeadSafe({ title: 'Notifications' })
</script>
3 changes: 3 additions & 0 deletions purple/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ def to_url(self, value):
rpc_api.DocumentAssignmentViewSet,
basename="documents-assignments",
)
rpc_router.register(
r"notifications", rpc_api.NotificationViewSet, basename="notifications"
)
rpc_router.register(r"labels", rpc_api.LabelViewSet)
rpc_router.register(r"rpc_person", rpc_api.RpcPersonViewSet)
rpc_router.register(
Expand Down
134 changes: 134 additions & 0 deletions purple_api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2638,6 +2638,72 @@ paths:
items:
$ref: "#/components/schemas/MailTemplate"
description: ""
/api/rpc/notifications/:
get:
operationId: notifications_list
description: |-
Current user's in-app notifications: broadcasts plus any addressed to them.

Broadcasts (recipient is null) are visible to every authenticated user;
targeted notifications only to their RpcPerson. Read state is per login user.
parameters:
- name: limit
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
- name: offset
required: false
in: query
description: The initial index from which to return the results.
schema:
type: integer
tags:
- purple
security:
- cookieAuth: []
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/PaginatedNotificationList"
description: ""
/api/rpc/notifications/mark_read/:
post:
operationId: notifications_mark_read
description: |-
Current user's in-app notifications: broadcasts plus any addressed to them.

Broadcasts (recipient is null) are visible to every authenticated user;
targeted notifications only to their RpcPerson. Read state is per login user.
tags:
- purple
security:
- cookieAuth: []
responses:
"204":
description: No response body
/api/rpc/notifications/unread_count/:
get:
operationId: notifications_unread_count
description: |-
Current user's in-app notifications: broadcasts plus any addressed to them.

Broadcasts (recipient is null) are visible to every authenticated user;
targeted notifications only to their RpcPerson. Read state is per login user.
tags:
- purple
security:
- cookieAuth: []
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/NotificationUnreadCount"
description: ""
/api/rpc/profile/:
get:
operationId: profile
Expand Down Expand Up @@ -4454,6 +4520,14 @@ components:
- $ref: "#/components/schemas/Name"
nullable: true
readOnly: true
EventTypeEnum:
enum:
- blocked
- unblocked
type: string
description: |-
* `blocked` - document blocked
* `unblocked` - document unblocked
FinalApproval:
type: object
description: Serialize final approval information for an RfcToBe
Expand Down Expand Up @@ -4933,6 +5007,43 @@ components:
nullable: true
required:
- role
Notification:
type: object
description: In-app notification, with a per-request unread flag from the read
watermark.
properties:
id:
type: integer
readOnly: true
event_type:
$ref: "#/components/schemas/EventTypeEnum"
rfc_to_be:
type: integer
nullable: true
description: Document this notification is about
draft_name:
type: string
readOnly: true
reasons:
type: array
items:
type: string
readOnly: true
created:
type: string
format: date-time
unread:
type: boolean
readOnly: true
required:
- event_type
NotificationUnreadCount:
type: object
properties:
count:
type: integer
required:
- count
PaginatedBaseDatatrackerPersonList:
type: object
required:
Expand Down Expand Up @@ -5002,6 +5113,29 @@ components:
type: array
items:
$ref: "#/components/schemas/DocumentComment"
PaginatedNotificationList:
type: object
required:
- count
- results
properties:
count:
type: integer
example: 123
next:
type: string
nullable: true
format: uri
example: http://api.example.org/accounts/?offset=400&limit=100
previous:
type: string
nullable: true
format: uri
example: http://api.example.org/accounts/?offset=200&limit=100
results:
type: array
items:
$ref: "#/components/schemas/Notification"
PaginatedRfcToBeList:
type: object
required:
Expand Down
Loading