/** * 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, 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 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; 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 }