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.
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 1 | import Constants from "expo-constants"; |
| 2 | import * as Device from "expo-device"; | |
| 3 | import * as Notifications from "expo-notifications"; | |
| 4 | import * as SecureStore from "expo-secure-store"; | |
| 5 | import { Platform } from "react-native"; | |
| 6 | ||
| 7 | import type { Client } from "./client"; | |
| 8 | import { IN_EXPO_GO } from "./oauth"; | |
| 9 | import { viewing } from "./viewing"; | |
| 10 | ||
| 11 | /** | |
| 12 | * Notifications on the phone. g1t's notify service sends them through | |
| 13 | * Expo's push service (FCM on Android, APNs on iOS) under the same rules | |
| 14 | * as browser push: what the person chose, never in Do Not Disturb, never | |
| 15 | * for a muted conversation, and not while a g1t tab or this app is in | |
| 16 | * front of them. The app registers its push token with the site's | |
| 17 | * `/-/notify` (`register_device`) once allowed, and takes it back on | |
| 18 | * signing out (`unregister_device`). | |
| 19 | * | |
| 20 | * Each push carries in `data` where it goes (`href`, a site path), its | |
| 21 | * `workspace`, and the `channel_id` and `thread_root` it is about. | |
| 22 | */ | |
| 23 | ||
| 24 | /** Android's channel for messages: what people turn on or off in the system's settings. */ | |
| 25 | export const MESSAGES_CHANNEL = "messages"; | |
| 26 | ||
| 27 | const TOKEN_KEY = "g1t.push-token"; | |
| 28 | ||
| 29 | export type PushData = { | |
| 30 | href?: string; | |
| 31 | workspace?: string; | |
| 32 | kind?: string; | |
| 33 | channel_id?: string | null; | |
| 34 | thread_root?: string | null; | |
| 35 | }; | |
| 36 | ||
| 37 | // A push that arrives while the app is open shows, unless it is about | |
| 38 | // the conversation on screen. | |
| 39 | Notifications.setNotificationHandler({ | |
| 40 | handleNotification: async (notification) => { | |
| 41 | const data = (notification.request.content.data ?? {}) as PushData; | |
| 42 | const here = !!data.channel_id && data.channel_id === viewing.channelId; | |
| 43 | return { shouldShowBanner: !here, shouldShowList: !here, shouldPlaySound: !here, shouldSetBadge: true }; | |
| 44 | }, | |
| 45 | }); | |
| 46 | ||
| 47 | export type PushState = "unsupported" | "denied" | "undetermined" | "granted"; | |
| 48 | ||
| 49 | /** Whether pushes can work here: a real phone, in a build of the app (Expo Go on Android has no push). */ | |
| 50 | export function pushSupported(): boolean { | |
| 51 | return Device.isDevice && !(IN_EXPO_GO && Platform.OS === "android"); | |
| 52 | } | |
| 53 | ||
| 54 | export async function pushState(): Promise<PushState> { | |
| 55 | if (!pushSupported()) return "unsupported"; | |
| 56 | const { status } = await Notifications.getPermissionsAsync(); | |
| 57 | return status === "granted" ? "granted" : status === "denied" ? "denied" : "undetermined"; | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * Asks to notify (once; after a no, the system's settings decide), and | |
| 62 | * registers the phone with g1t. Safe to call on every launch: the token is | |
| 63 | * registered again, which keeps it fresh. | |
| 64 | */ | |
| 65 | export async function registerForPush(client: Client, ask: boolean): Promise<PushState> { | |
| 66 | if (!pushSupported()) return "unsupported"; | |
| 67 | if (Platform.OS === "android") { | |
| 68 | await Notifications.setNotificationChannelAsync(MESSAGES_CHANNEL, { | |
| 69 | name: "Messages", | |
| 70 | description: "Messages to you, mentions, replies, and agents waiting on you.", | |
| 71 | importance: Notifications.AndroidImportance.HIGH, | |
| 72 | vibrationPattern: [0, 120, 80, 120], | |
| 73 | lightColor: "#b6a8ff", | |
| 74 | showBadge: true, | |
| 75 | }); | |
| 76 | } | |
| 77 | let { status } = await Notifications.getPermissionsAsync(); | |
| 78 | if (status !== "granted" && ask) status = (await Notifications.requestPermissionsAsync()).status; | |
| 79 | if (status !== "granted") return status === "denied" ? "denied" : "undetermined"; | |
| 80 | ||
| 81 | const projectId = (Constants.expoConfig?.extra?.eas as { projectId?: string } | undefined)?.projectId ?? Constants.easConfig?.projectId; | |
| 82 | const { data: token } = await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined); | |
| 83 | await client.siteRaw("/-/notify", { | |
| 84 | method: "POST", | |
| 85 | body: JSON.stringify({ | |
| 86 | intent: "register_device", | |
| 87 | device: { token, platform: Platform.OS === "ios" ? "ios" : "android", name: Device.deviceName ?? Device.modelName ?? null }, | |
| 88 | }), | |
| 89 | }); | |
| 90 | await SecureStore.setItemAsync(TOKEN_KEY, token); | |
| 91 | return "granted"; | |
| 92 | } | |
| 93 | ||
| 94 | /** Takes the phone off the person's notifications, on signing out. */ | |
| 95 | export async function unregisterPush(client: Client): Promise<void> { | |
| 96 | const token = await SecureStore.getItemAsync(TOKEN_KEY).catch(() => null); | |
| 97 | if (!token) return; | |
| 98 | await client | |
| 99 | .siteRaw("/-/notify", { method: "POST", body: JSON.stringify({ intent: "unregister_device", token }) }) | |
| 100 | .catch(() => undefined); | |
| 101 | await SecureStore.deleteItemAsync(TOKEN_KEY).catch(() => undefined); | |
| 102 | await Notifications.setBadgeCountAsync(0).catch(() => undefined); | |
| 103 | } | |
| A conversation keeps its newest message in view when the keyboard opens, on the site and in the phone app, and the phone app's pushes are ready for a development build. | 104 | |
| 105 | /** | |
| 106 | * Sends a test notification to this person's phones and browsers | |
| 107 | * (`POST /-/notify` `test`), whatever is in front of them: the way to see | |
| 108 | * that a build gets pushes. Answers how many it reached. | |
| 109 | */ | |
| 110 | export async function sendTestPush(client: Client): Promise<number> { | |
| 111 | const answer = await client.siteRaw<{ ok: boolean; pushed: number }>("/-/notify", { method: "POST", body: JSON.stringify({ intent: "test" }) }); | |
| 112 | return answer.pushed ?? 0; | |
| 113 | } |