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 | 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 | ||
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 47 | export type PushState = "unsupported" | "off" | "denied" | "undetermined" | "granted"; |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 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 | ||
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 54 | export async function pushState(server?: { push?: boolean }): Promise<PushState> { |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 55 | if (!pushSupported()) return "unsupported"; |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 56 | if (server?.push === false) return "off"; |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 57 | const { status } = await Notifications.getPermissionsAsync(); |
| 58 | return status === "granted" ? "granted" : status === "denied" ? "denied" : "undetermined"; | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Asks to notify (once; after a no, the system's settings decide), and | |
| 63 | * registers the phone with g1t. Safe to call on every launch: the token is | |
| 64 | * registered again, which keeps it fresh. | |
| 65 | */ | |
| 66 | export async function registerForPush(client: Client, ask: boolean): Promise<PushState> { | |
| 67 | if (!pushSupported()) return "unsupported"; | |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 68 | // A g1t that sends phones nothing (PHONE_PUSH=off): no asking, nothing to register. |
| 69 | if (client.server.push === false) return "off"; | |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 70 | if (Platform.OS === "android") { |
| 71 | await Notifications.setNotificationChannelAsync(MESSAGES_CHANNEL, { | |
| 72 | name: "Messages", | |
| The phone app no longer has g1t's own agents, to match the site. The Agents tab is gone from the bar, and Customize (skills, connectors and plugins) is gone with its row in More. New message finds and messages people only, and its search and heading say so. A card an agent posted earlier still shows with its title, state, details and links, but its other buttons are no longer drawn, since chat no longer answers them; an old agent's messages and direct messages still show, under the name chat gives them, marked Deleted agent. The app stops asking for agents:run when it signs in. An old g1t:// link to Agents, the skill library, Customize or the Marketplace opens Home, while the integrations directory opens on the site. Notifications, the sign-in screen and the access-token error no longer mention agents waiting on you or agent tokens, and the sign-in screen speaks of reviewing what you and your coding agents make. Pull requests still show the coding agent they were made with. | 73 | description: "Messages to you, mentions and replies.", |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 74 | importance: Notifications.AndroidImportance.HIGH, |
| 75 | vibrationPattern: [0, 120, 80, 120], | |
| 76 | lightColor: "#b6a8ff", | |
| 77 | showBadge: true, | |
| 78 | }); | |
| 79 | } | |
| 80 | let { status } = await Notifications.getPermissionsAsync(); | |
| 81 | if (status !== "granted" && ask) status = (await Notifications.requestPermissionsAsync()).status; | |
| 82 | if (status !== "granted") return status === "denied" ? "denied" : "undetermined"; | |
| 83 | ||
| 84 | const projectId = (Constants.expoConfig?.extra?.eas as { projectId?: string } | undefined)?.projectId ?? Constants.easConfig?.projectId; | |
| 85 | const { data: token } = await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined); | |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 86 | const answer = await client.siteRaw<{ ok: boolean; off?: boolean }>("/-/notify", { |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 87 | method: "POST", |
| 88 | body: JSON.stringify({ | |
| 89 | intent: "register_device", | |
| 90 | device: { token, platform: Platform.OS === "ios" ? "ios" : "android", name: Device.deviceName ?? Device.modelName ?? null }, | |
| 91 | }), | |
| 92 | }); | |
| The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5) | 93 | if (answer.off) return "off"; |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 94 | await SecureStore.setItemAsync(TOKEN_KEY, token); |
| 95 | return "granted"; | |
| 96 | } | |
| 97 | ||
| 98 | /** Takes the phone off the person's notifications, on signing out. */ | |
| 99 | export async function unregisterPush(client: Client): Promise<void> { | |
| 100 | const token = await SecureStore.getItemAsync(TOKEN_KEY).catch(() => null); | |
| 101 | if (!token) return; | |
| 102 | await client | |
| 103 | .siteRaw("/-/notify", { method: "POST", body: JSON.stringify({ intent: "unregister_device", token }) }) | |
| 104 | .catch(() => undefined); | |
| 105 | await SecureStore.deleteItemAsync(TOKEN_KEY).catch(() => undefined); | |
| 106 | await Notifications.setBadgeCountAsync(0).catch(() => undefined); | |
| 107 | } | |
| 108 | ||
| 109 | /** | |
| 110 | * Sends a test notification to this person's phones and browsers | |
| 111 | * (`POST /-/notify` `test`), whatever is in front of them: the way to see | |
| 112 | * that a build gets pushes. Answers how many it reached. | |
| 113 | */ | |
| 114 | export async function sendTestPush(client: Client): Promise<number> { | |
| 115 | const answer = await client.siteRaw<{ ok: boolean; pushed: number }>("/-/notify", { method: "POST", body: JSON.stringify({ intent: "test" }) }); | |
| 116 | return answer.pushed ?? 0; | |
| 117 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.