| 1 | import { parseEventContext, parseTimelineItem } from './types'; |
| 2 | import type { EventContext, TimelineItem } from './types'; |
| 3 | |
| 4 | export interface MentionKey { room_id: string; event_id: string } |
| 5 | export type MentionReason = 'Direct' | 'Reply' | 'Room'; |
| 6 | export type MentionCaptureState = |
| 7 | | { kind: 'Pending' | 'Partial' | 'Ready' | 'Unavailable' } |
| 8 | | { kind: 'Failed'; message: string; retry_at_ms: number }; |
| 9 | export interface MentionEntry { |
| 10 | key: MentionKey; timestamp_ms: number; discovered_at_ms: number; discovery_revision: number; revision: number; |
| 11 | event: TimelineItem; reasons: MentionReason[]; read: boolean; capture: MentionCaptureState; |
| 12 | room_name: string | null; notification_suppressed: boolean; |
| 13 | } |
| 14 | export interface MentionCoverage { |
| 15 | earliest_timestamp_ms: number | null; historical_supported: boolean | null; recovering: boolean; |
| 16 | incomplete_history: boolean; gaps: MentionGap[]; truncated: boolean; unresolved_candidates: number; |
| 17 | } |
| 18 | export interface MentionGap { room_id: string | null; reason: string; token: string | null } |
| 19 | export interface MentionsBoundary { account: string; revision: number } |
| 20 | export interface MentionsPage { |
| 21 | entries: MentionEntry[]; next_cursor: string | null; end_reached: boolean; coverage: MentionCoverage; |
| 22 | unread_count: number; revision: number; boundary: MentionsBoundary; |
| 23 | } |
| 24 | export interface SavedMentionContext { key: MentionKey; state: MentionCaptureState; context: EventContext | null; revision: number } |
| 25 | export interface MentionsUpdate { snapshot: MentionsPage; upserts: MentionEntry[]; removed: MentionKey[] } |
| 26 | |
| 27 | type Obj = Record<string, unknown>; |
| 28 | const obj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v); |
| 29 | const str = (v: unknown): v is string => typeof v === 'string'; |
| 30 | const bool = (v: unknown): v is boolean => typeof v === 'boolean'; |
| 31 | const nat = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0; |
| 32 | function key(v: unknown): MentionKey | null { |
| 33 | if (!obj(v) || !str(v.room_id) || !v.room_id || !str(v.event_id) || !v.event_id) return null; |
| 34 | return { room_id: v.room_id, event_id: v.event_id }; |
| 35 | } |
| 36 | function capture(v: unknown): MentionCaptureState | null { |
| 37 | if (str(v) && ['Pending', 'Partial', 'Ready', 'Unavailable'].includes(v)) return { kind: v as 'Pending' | 'Partial' | 'Ready' | 'Unavailable' }; |
| 38 | if (obj(v) && obj(v.Failed) && str(v.Failed.message) && nat(v.Failed.retry_at_ms)) return { kind: 'Failed', message: v.Failed.message, retry_at_ms: v.Failed.retry_at_ms }; |
| 39 | return null; |
| 40 | } |
| 41 | function entry(v: unknown): MentionEntry | null { |
| 42 | if (!obj(v)) return null; |
| 43 | const k = key(v.key), ev = parseTimelineItem(v.event), c = capture(v.capture); |
| 44 | if (!k || !ev || !c || !nat(v.timestamp_ms) || !nat(v.discovered_at_ms) || !nat(v.discovery_revision) || !nat(v.revision) || !Array.isArray(v.reasons) || !v.reasons.every(x => str(x) && ['Direct','Reply','Room'].includes(x)) || !bool(v.read) || !(v.room_name === null || str(v.room_name)) || !bool(v.notification_suppressed)) return null; |
| 45 | return { key:k, timestamp_ms:v.timestamp_ms, discovered_at_ms:v.discovered_at_ms, discovery_revision:v.discovery_revision, revision:v.revision, event:ev, reasons:v.reasons as MentionReason[], read:v.read, capture:c, room_name:v.room_name, notification_suppressed:v.notification_suppressed }; |
| 46 | } |
| 47 | function gap(v: unknown): MentionGap | null { |
| 48 | if (!obj(v) || !(v.room_id === null || (str(v.room_id) && v.room_id)) || !str(v.reason) || !(v.token === null || str(v.token))) return null; |
| 49 | return { room_id:v.room_id, reason:v.reason, token:v.token }; |
| 50 | } |
| 51 | function page(v: unknown): MentionsPage | null { |
| 52 | if (!obj(v) || !obj(v.coverage) || !Array.isArray(v.entries) || !Array.isArray(v.coverage['gaps'])) return null; |
| 53 | const entries=v.entries.map(entry), cov=v.coverage as Obj; |
| 54 | const gaps=(cov['gaps'] as unknown[]).map(gap); |
| 55 | if (entries.some(x=>!x) || gaps.some(x=>!x) || !(v.next_cursor === null || str(v.next_cursor)) || !bool(v.end_reached) || !(cov['earliest_timestamp_ms'] === null || nat(cov['earliest_timestamp_ms'])) || !(cov['historical_supported'] === null || bool(cov['historical_supported'])) || !bool(cov['recovering']) || !bool(cov['incomplete_history']) || !bool(cov['truncated']) || !nat(cov['unresolved_candidates']) || !nat(v.unread_count) || !nat(v.revision) || !obj(v.boundary) || !str(v.boundary['account']) || !v.boundary['account'] || !nat(v.boundary['revision'])) return null; |
| 56 | return { entries:entries as MentionEntry[], next_cursor:v.next_cursor as string|null, end_reached:v.end_reached, coverage:{earliest_timestamp_ms:cov['earliest_timestamp_ms'] as number|null,historical_supported:cov['historical_supported'] as boolean|null,recovering:cov['recovering'],incomplete_history:cov['incomplete_history'],gaps:gaps as MentionGap[],truncated:cov['truncated'],unresolved_candidates:cov['unresolved_candidates']}, unread_count:v.unread_count, revision:v.revision, boundary:{account:v.boundary['account'],revision:v.boundary['revision']} }; |
| 57 | } |
| 58 | export function parseMentionsPage(raw: unknown): MentionsPage | null { try { return page(raw); } catch { return null; } } |
| 59 | export function parseSavedMentionContext(raw: unknown): SavedMentionContext | null { |
| 60 | try { |
| 61 | if (!obj(raw)) return null; |
| 62 | const k=key(raw.key), state=capture(raw.state), context=raw.context === null ? null : parseEventContext(raw.context); |
| 63 | if (!k || !state || (raw.context !== null && !context) || !nat(raw.revision)) return null; |
| 64 | return {key:k,state,context,revision:raw.revision}; |
| 65 | } catch { return null; } |
| 66 | } |
| 67 | export function parseMentionsUpdate(raw: unknown): MentionsUpdate | null { |
| 68 | try { |
| 69 | if (!obj(raw) || !Array.isArray(raw.upserts) || !Array.isArray(raw.removed)) return null; |
| 70 | const snapshot=page(raw.snapshot), upserts=raw.upserts.map(entry), removed=raw.removed.map(key); |
| 71 | if (!snapshot || upserts.some(x=>!x) || removed.some(x=>!x)) return null; |
| 72 | return {snapshot,upserts:upserts as MentionEntry[],removed:removed as MentionKey[]}; |
| 73 | } catch { return null; } |
| 74 | } |
| 75 | export function mentionKey(key: MentionKey): string { return JSON.stringify([key.room_id,key.event_id]); } |