| 1 | /** |
| 2 | * Push to the phone app, through Expo's push service, which hands each |
| 3 | * message to FCM on Android and APNs on iOS. The app registers the token |
| 4 | * Expo gave it (`ExponentPushToken[…]`); the feed keeps it as a device. |
| 5 | * |
| 6 | * Expo answers a batch of up to 100 messages with one ticket each, in the |
| 7 | * same order. A ticket saying `DeviceNotRegistered` means the app is gone |
| 8 | * from that phone (uninstalled, or its notifications turned off for good): |
| 9 | * the caller drops the device. Receipts, which say whether FCM or APNs took |
| 10 | * the message, are not fetched: a device that stops working shows up in a |
| 11 | * later ticket the same way. |
| 12 | * |
| 13 | * Pure but for `sendExpo`'s request, so the shapes are tested apart from |
| 14 | * the feed. |
| 15 | */ |
| 16 | import type { FeedNotification } from "@g1t/contracts"; |
| 17 | |
| 18 | import type { PushPayload } from "./prefs.ts"; |
| 19 | |
| 20 | export const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"; |
| 21 | |
| 22 | /** The most messages Expo takes in one request. */ |
| 23 | export const EXPO_BATCH = 100; |
| 24 | |
| 25 | /** The Android notification channel the app makes for messages (apps/mobile). */ |
| 26 | export const ANDROID_CHANNEL = "messages"; |
| 27 | |
| 28 | /** The longest token taken; Expo's are about 40 characters. */ |
| 29 | const MAX_TOKEN = 200; |
| 30 | const MAX_NAME = 100; |
| 31 | |
| 32 | const TOKEN = /^Expo(nent)?PushToken\[[^\s[\]]+\]$/; |
| 33 | |
| 34 | /** A phone the app runs on, as the feed keeps it. */ |
| 35 | export type Device = { token: string; platform: "android" | "ios"; name: string | null }; |
| 36 | |
| 37 | /** A device as the app sent it, checked; null when it is not one. The name is trimmed to 100 characters. */ |
| 38 | export function cleanDevice(value: unknown): Device | null { |
| 39 | if (!value || typeof value !== "object") return null; |
| 40 | const d = value as Record<string, unknown>; |
| 41 | const token = typeof d.token === "string" ? d.token.trim() : ""; |
| 42 | if (!token || token.length > MAX_TOKEN || !TOKEN.test(token)) return null; |
| 43 | if (d.platform !== "android" && d.platform !== "ios") return null; |
| 44 | const name = typeof d.name === "string" ? d.name.trim().slice(0, MAX_NAME) : ""; |
| 45 | return { token, platform: d.platform, name: name || null }; |
| 46 | } |
| 47 | |
| 48 | /** One message as Expo takes it. */ |
| 49 | export type ExpoMessage = { |
| 50 | to: string; |
| 51 | title: string; |
| 52 | body: string; |
| 53 | sound: "default"; |
| 54 | priority: "high" | "normal"; |
| 55 | /** Seconds Expo and the platforms keep trying, the same as a browser push. */ |
| 56 | ttl: number; |
| 57 | /** Android: the channel it shows under. */ |
| 58 | channelId: string; |
| 59 | /** iOS: the notifications of one conversation group together. */ |
| 60 | threadId: string; |
| 61 | /** What the app opens when it is pressed. */ |
| 62 | data: { |
| 63 | href: string; |
| 64 | workspace: string; |
| 65 | kind: PushPayload["kind"]; |
| 66 | tag: string; |
| 67 | channel_id: string | null; |
| 68 | thread_root: string | null; |
| 69 | card: PushPayload["card"]; |
| 70 | }; |
| 71 | }; |
| 72 | |
| 73 | /** The message for each token: what a browser push says, with where it is in chat for the app. */ |
| 74 | export function expoMessages( |
| 75 | tokens: string[], |
| 76 | payload: PushPayload, |
| 77 | notification: Pick<FeedNotification, "channel_id" | "thread_root">, |
| 78 | ): ExpoMessage[] { |
| 79 | return tokens.map((to) => ({ |
| 80 | to, |
| 81 | title: payload.title, |
| 82 | body: payload.body, |
| 83 | sound: "default", |
| 84 | priority: payload.urgent ? "high" : "normal", |
| 85 | ttl: 12 * 3600, |
| 86 | channelId: ANDROID_CHANNEL, |
| 87 | threadId: payload.tag, |
| 88 | data: { |
| 89 | href: payload.href, |
| 90 | workspace: payload.workspace, |
| 91 | kind: payload.kind, |
| 92 | tag: payload.tag, |
| 93 | channel_id: notification.channel_id ?? null, |
| 94 | thread_root: notification.thread_root ?? null, |
| 95 | card: payload.card, |
| 96 | }, |
| 97 | })); |
| 98 | } |
| 99 | |
| 100 | /** What a batch came to: how many Expo took, the tokens that are gone, and what went wrong otherwise. */ |
| 101 | export type ExpoResult = { sent: number; gone: string[]; errors: string[] }; |
| 102 | |
| 103 | /** Expo's answer to a batch, read against the tokens it was for (tickets come in the same order). */ |
| 104 | export function readTickets(tokens: string[], answer: unknown): ExpoResult { |
| 105 | const result: ExpoResult = { sent: 0, gone: [], errors: [] }; |
| 106 | const body = (answer && typeof answer === "object" ? answer : {}) as { data?: unknown; errors?: unknown }; |
| 107 | if (!Array.isArray(body.data)) { |
| 108 | result.errors.push(`no tickets: ${JSON.stringify(body.errors ?? answer).slice(0, 300)}`); |
| 109 | return result; |
| 110 | } |
| 111 | tokens.forEach((token, i) => { |
| 112 | const ticket = (body.data as unknown[])[i] as { status?: unknown; message?: unknown; details?: { error?: unknown } } | undefined; |
| 113 | if (ticket?.status === "ok") result.sent++; |
| 114 | else if (ticket?.details?.error === "DeviceNotRegistered") result.gone.push(token); |
| 115 | else result.errors.push(String(ticket?.details?.error ?? ticket?.message ?? "no ticket").slice(0, 300)); |
| 116 | }); |
| 117 | return result; |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * Sends one notification to every token, 100 to a request, the requests at |
| 122 | * once. A request that fails counts nothing and is reported in `errors`; |
| 123 | * the others still count. |
| 124 | */ |
| 125 | export async function sendExpo( |
| 126 | tokens: string[], |
| 127 | payload: PushPayload, |
| 128 | notification: Pick<FeedNotification, "channel_id" | "thread_root">, |
| 129 | options: { accessToken?: string | null } = {}, |
| 130 | fetcher: typeof fetch = fetch, |
| 131 | ): Promise<ExpoResult> { |
| 132 | const headers: Record<string, string> = { accept: "application/json", "content-type": "application/json" }; |
| 133 | // Needed only when the Expo project turns on enhanced push security. |
| 134 | if (options.accessToken) headers.authorization = `Bearer ${options.accessToken}`; |
| 135 | const batches: string[][] = []; |
| 136 | for (let i = 0; i < tokens.length; i += EXPO_BATCH) batches.push(tokens.slice(i, i + EXPO_BATCH)); |
| 137 | const results = await Promise.allSettled( |
| 138 | batches.map(async (batch) => { |
| 139 | const response = await fetcher(EXPO_PUSH_URL, { method: "POST", headers, body: JSON.stringify(expoMessages(batch, payload, notification)) }); |
| 140 | const answer = await response.json().catch(() => null); |
| 141 | if (!response.ok) return { sent: 0, gone: [], errors: [`Expo answered ${response.status}: ${JSON.stringify(answer).slice(0, 300)}`] }; |
| 142 | return readTickets(batch, answer); |
| 143 | }), |
| 144 | ); |
| 145 | const total: ExpoResult = { sent: 0, gone: [], errors: [] }; |
| 146 | for (const result of results) { |
| 147 | if (result.status === "rejected") { |
| 148 | total.errors.push(String(result.reason).slice(0, 300)); |
| 149 | continue; |
| 150 | } |
| 151 | total.sent += result.value.sent; |
| 152 | total.gone.push(...result.value.gone); |
| 153 | total.errors.push(...result.value.errors); |
| 154 | } |
| 155 | return total; |
| 156 | } |