Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.