main
/**
* Notification payloads are a free JSON column (`serde_json::Value` on the
* server, `null` in the generated client), so nothing types them. These are
* the shapes the writers in the API actually emit:
*
* ```text
* mention / reply issue space_name board_id issue_id comment_id autoincrement_id
* mention channel room_id message_id seq thread_id space_name
* assignment space_name board_id issue_id autoincrement_id
* invite channel room_id invite_id accept_code space_name
* invite space space_id space_name invite_id accept_code
* system reminder room_id message_id space_name
* ```
*
* `space_name` is always the route slug (`lower_name`), which is what
* `/app/{space}/…` addresses.
*/
import { t } from '@app/i18n'
/**
* A notification as far as routing is concerned: the type picks the shape and
* `data` is the server's free JSON column. Both the HTTP row and the WS event
* satisfy it, so a toast and an inbox row resolve to the same destination.
*/
export interface NotificationLike {
notification_type: string
data?: unknown
}
export interface NotificationPayload {
spaceName?: string
requestId?: string
applicantId?: string
kind?: string
boardId?: string
issueNumber?: string
roomId?: string
messageId?: string
inviteId?: string
acceptCode?: string
}
/** Where a notification came from, once its payload is read. */
export type InviteKind = 'space' | 'room' | 'unknown'
function str(source: Record<string, unknown>, key: string): string | undefined {
const value = source[key]
if (typeof value === 'string' && value) return value
// `autoincrement_id` is a JSON number on the server.
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
return undefined
}
export function readPayload(row: { data?: unknown }): NotificationPayload {
const raw = row.data as unknown
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
const source = raw as Record<string, unknown>
return {
spaceName: str(source, 'space_name'),
boardId: str(source, 'board_id'),
issueNumber: str(source, 'autoincrement_id'),
roomId: str(source, 'room_id'),
messageId: str(source, 'message_id'),
inviteId: str(source, 'invite_id'),
acceptCode: str(source, 'accept_code'),
requestId: str(source, 'request_id'),
applicantId: str(source, 'applicant_id'),
kind: str(source, 'kind'),
}
}
export interface NotificationTarget {
href: string
/** Verb for the row's jump affordance, phrased for what it opens. */
label: string
}
/** A space's route segment. `@me` is a literal we own, so it must not be
* encoded: the shell matches the personal scope off the raw pathname. */
const scope = (name?: string) => (name ? encodeURIComponent(name) : '@me')
const roomHref = (payload: NotificationPayload) => `/app/${scope(payload.spaceName)}/chat/${payload.roomId}`
/**
* The screen this notification is about, or `null` when it points at nothing
* (an email-only invite, a row written before payloads carried a slug). Rows
* without a target stay readable but are not clickable.
*/
export function notificationTarget(row: NotificationLike): NotificationTarget | null {
const payload = readPayload(row)
// A join request asks a space manager for a decision, so it lands on the
// settings section that can make it rather than on the space's chat column.
if (payload.kind === 'join_request_submitted' && payload.spaceName) {
return {
href: `/app/${scope(payload.spaceName)}?settings=join-requests`,
label: t('notifications.target.joinRequests'),
}
}
// An issue row opens the ticket itself, not the board's column list.
if (payload.spaceName && payload.boardId && payload.issueNumber) {
return {
href: `/app/${scope(payload.spaceName)}/board/${payload.boardId}?issue=${encodeURIComponent(payload.issueNumber)}`,
label: t('notifications.target.issue', { number: payload.issueNumber }),
}
}
if (payload.roomId) {
const jump = payload.messageId ? `?jump=${encodeURIComponent(payload.messageId)}` : ''
return { href: `${roomHref(payload)}${jump}`, label: t('notifications.target.room') }
}
if (payload.spaceName) {
return { href: `/app/${scope(payload.spaceName)}`, label: t('notifications.target.space') }
}
return null
}
/**
* What an invite row can act on: which accept / reject endpoint to call, with
* the credential it needs. `null` means the row predates in-app accepting or
* was mailed to an address instead of an account, so there is nothing to send.
*/
export function inviteDecision(row: NotificationLike): { kind: Exclude<InviteKind, 'unknown'>; code: string } | null {
const payload = readPayload(row)
if (!payload.acceptCode) return null
if (payload.roomId) return { kind: 'room', code: payload.acceptCode }
if (payload.spaceName) return { kind: 'space', code: payload.acceptCode }
return null
}