Skip to content

Commit

The phone app: Chat in full with phone notifications, plus Code and Customize (#4)

The phone app: Chat in full with phone notifications, plus Code and Customize (Expo, Android first)

syntaqxcommitted Parentsca6b57db62241fBrowse files
114 files+6758−1180/114 viewed
+2−1
4242 | Slides, designs and dashboards as artifacts | Coming |
4343 | Scratchpads: tools, reports and agents anyone can build on a copy of the data, promoted after review | Coming |
4444 | Desktop app for Windows, macOS and Linux (Tauri): the site in a window of its own, notifications from the system, a badge, g1t:// links, updates that install themselves | Preview |
45−| A runner inside the desktop app, and a phone app (Expo) | Coming |
45+| A runner inside the desktop app | Coming |
46+| A phone app for Android and iOS (Expo, in `apps/mobile`): Chat, Code and Customize, signed in with OAuth; Android first | Coming |
4647
4748 ### Agents
4849
+1−0
8282 { label: 'Home', slug: 'guides/home' },
8383 { label: 'Notifications', slug: 'guides/notifications' },
8484 { label: 'The desktop app', slug: 'guides/desktop' },
85+ { label: 'The phone app', slug: 'guides/mobile' },
8586 { label: 'Search and Explore', slug: 'guides/search' },
8687 { label: 'Status and incidents', slug: 'guides/status' },
8788 ],
+34−5
589589 | git | As the password, with your username. |
590590 | API | `Authorization: Bearer g1t_…` |
591591 | MCP | The same header, set when you add the server. |
592−| The website | The same header on every request, from automation that drives a browser. Only a token with **Use the website as you** turned on. See [use a token on the website](#use-a-token-on-the-website). |
592+| The website | The same header on every request, from automation that drives a browser. Only a token with **Use the website as you** turned on. See [use a token on the website](#use-a-token-on-the-website). A token with [Chat's scopes](#chat-and-notifications-without-the-website) reaches Chat without it. |
593593
594594 A token is shown once, when it is created; g1t stores only a hash of it.
595595 If you lose one, delete it and create another. Delete a token the moment
762762 | Deleting a workspace, and giving it to another owner | A workspace's General settings, and its Members page |
763763 | Payment methods: the billing portal, adding a card, subscribing and buying AI credit | A workspace's Billing |
764764
765+### Chat and notifications without the website
766+
767+[Chat](/guides/chat/) has no API of its own: an application such as a
768+phone app reads and sends messages through the same addresses the website's
769+chat pages call. A token with Chat's scopes, from
770+[OAuth](#signing-in-with-oauth) or a personal token, may use those
771+addresses without **Use the website as you**, and nothing else on the site:
772+
773+| Request | Needs |
774+| --- | --- |
775+| `GET /{workspace}/-/chat/api`, `GET /{workspace}/-/chat/person/{username}` | `chat:read` |
776+| `POST /{workspace}/-/chat/api` | `chat:write` |
777+| `GET /-/live/ticket?path=…`, for Chat's live socket `/{workspace}/-/chat/live` or your feed `/-/live` | `chat:read` |
778+| `GET /-/notify`, registering for push notifications and reading your notification settings | `notifications:read` |
779+| `POST /-/notify`, changing them | `notifications:write` |
780+
781+The socket opens with the ticket, and the token is checked again then, so a
782+token that has lost `chat:read` opens nothing. Such a token gets no ticket
783+for an artifact's socket. Every other page loads signed out with it, and
784+every other data request and form post answers `401`, as for any token not
785+accepted. The token must have been given these scopes by name: a
786+full-access token, or one made before tokens had scopes, reaches Chat only
787+with **Use the website as you**. No preset holds them either, so an
788+application gets Chat only when it asks for it by name.
789+
765790 ### Permissions
766791
767792 A permission is a resource and a level. A higher level includes the lower
806831 | --- | --- | --- |
807832 | Your account | read, read and write | `account:read`, `account:write` |
808833 | Notifications | read, read and write | `notifications:read`, `notifications:write` |
834+| Chat | read, read and write | `chat:read`, `chat:write` |
809835
810836 What each level lets a token do is in [scopes](#scopes). On the form, each
811837 row says it for the level chosen, and admin and delete levels are shown in
839865
840866 | Preset | Permissions |
841867 | --- | --- |
842−| Read only | Every resource at read. Changes nothing. |
843−| Agent | Every resource at read except Self-hosted runners, and Code, Issues, Pull requests, Memory and context and Notifications at read and write, and g1t agents at run. Reads everything, works on issues and pull requests, pushes code, puts g1t to work, and answers your inbox. No admin level. |
868+| Read only | Every resource at read except Chat. Changes nothing. |
869+| Agent | Every resource at read except Self-hosted runners and Chat, and Code, Issues, Pull requests, Memory and context and Notifications at read and write, and g1t agents at run. Reads everything, works on issues and pull requests, pushes code, puts g1t to work, and answers your inbox. No admin level. |
844870 | CI | Repositories: read; Code, Packages, Workflows, Checks and statuses, and Deployments: read and write. Clones and pushes code, pushes and pulls packages, runs workflows, and reports [checks](/guides/checks/) and deployments. |
845871
846872 A new personal token starts on Read only; a new workspace token on CI.
888914 | `account:write` | Change your email addresses, make invites, answer invitations, pin projects and star repositories |
889915 | `notifications:read` | See your [notifications](/guides/notifications/), its threads, and what you subscribe to and watch |
890916 | `notifications:write` | Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories |
917+| `chat:read` | Read the conversations you are in: channels, direct messages, threads and reactions, live. Not in any preset. See [Chat and notifications without the website](#chat-and-notifications-without-the-website). |
918+| `chat:write` | Send, edit and delete your messages, react, mark conversations read, and answer cards in chat. Not in any preset. |
891919 | `workspace:read` | Read workspace settings, invites, integrations, model routes and [teams](/guides/teams/) |
892920 | `workspace:admin` | Create and delete workspaces, invite members, manage teams, connect integrations |
893921 | `billing:read` | See a workspace's [usage, budget, AI credit and invoices](/guides/usage-and-billing/) |
10981126 everything you can.
10991127
11001128 An application that asks for no scopes in particular gets the
1101−[Agent preset](#presets): every `read` scope except `runners:read`, and `code:write`,
1129+[Agent preset](#presets): every `read` scope except `runners:read` and `chat:read`, and `code:write`,
11021130 `issues:write`, `pull_requests:write`, `agents:run`, `memory:write` and
11031131 `notifications:write`.
11041132 It never gets an admin scope unless it asks for one and you leave it
1105−ticked.
1133+ticked, and it gets `chat:read` and `chat:write` only by asking for them;
1134+with them it can use [Chat](#chat-and-notifications-without-the-website).
11061135
11071136 Applications you have approved are listed in
11081137 [Settings → Connected applications](https://g1t.sh/settings/applications),
+1−1
4545
4646 | Choose | For an agent |
4747 | --- | --- |
48−| Scopes | The **Agent** preset: every `read` scope except `runners:read`, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run`, `memory:write` and `notifications:write`. Untick `agents:run` if it should not start g1t's agents, which spends the workspace's money. |
48+| Scopes | The **Agent** preset: every `read` scope except `runners:read` and `chat:read`, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run`, `memory:write` and `notifications:write`. Untick `agents:run` if it should not start g1t's agents, which spends the workspace's money. |
4949 | Expires | The shortest that fits the work, such as 30 days. |
5050
5151 A token reaches every workspace and repository you can. To keep an agent
+9−0
391391 half in `VAPID_PUBLIC_KEY` in `services/notify/wrangler.jsonc` and the
392392 private half in the secret `VAPID_PRIVATE_KEY`.
393393
394+Pushes to the phone app go through Expo's push service and need nothing
395+set. If the Expo project the app is built from turns on **enhanced push
396+security**, put an Expo access token in the notify Worker's secret
397+`EXPO_ACCESS_TOKEN`:
398+
399+```sh
400+cd services/notify && npx wrangler secret put EXPO_ACCESS_TOKEN
401+```
402+
394403 ## Rate limits
395404
396405 Every public surface that costs money or sends something is behind a
+1−1
113113 page is open, the window shows the web view's own error until it is
114114 back; starting without a network shows the app's own page, with a retry.
115115 - **Notarization on macOS**, so the first open needs no right-click.
116−- **A phone app.**
116+- **A phone app.** It is being built: see [the phone app](/guides/mobile/).
117117
118118 ## For operators: releasing the app
119119
+148−0
1+---
2+title: The phone app
3+description: g1t on Android and iOS, built with Expo. Chat with people and agents, live, with notifications on your phone, and Code and Customize, signed in with g1t's own OAuth. Not in the stores yet; this is how it works and how to build it.
4+---
5+
6+The phone app is g1t on Android and iOS, with its own screens for what
7+you do away from a desk: talking with your team and its agents in Chat,
8+with notifications on your phone, reviewing what agents made in Code,
9+and seeing the skills, connectors and plugins your workspace can use in
10+Customize. Everything else opens on the site, in your phone's browser.
11+
12+It is not in Google Play or the App Store yet. Android comes first. Until
13+then, it is built from `apps/mobile` in
14+[g1t's repository](https://g1t.sh/flagon-io/g1t), as
15+[For operators](#for-operators-building-and-releasing) says.
16+
17+The bar along the bottom is the site's phone bar:
18+
19+| Tab | What is on it |
20+| --- | --- |
21+| **Home** | Coming. Opens Home on the site meanwhile. |
22+| **Chat** | Your starred conversations, channels and direct messages, with what is unread, moving live. See [Chat](#chat). |
23+| **Notifications** | Coming. Opens your notifications on the site meanwhile; messages to you already arrive [on the phone](#notifications). |
24+| **Agents** | Coming. Opens Agents on the site meanwhile; message any agent from Chat. |
25+| **Code** | The workspace's repositories, each with its pull requests and issues. A pull request shows its description, checks, conversation and changed files, and you can approve it or ask for changes. An issue shows the pull requests made for it, and you can comment. Shown when you use Code in the workspace. |
26+| **More** | **Customize** (skills, connectors and plugins), whether this phone gets notifications, your workspaces, how you signed in, and **Sign out**. |
27+
28+## Chat
29+
30+Chat on the phone is the site's Chat: the same conversations, as they
31+happen.
32+
33+- **Conversations** show messages as the site draws them: bold, italic,
34+ code, lists, quotes, tables and links, with `@mentions`, `#channels`
35+ and `#123` references standing out. Messages by one person a few minutes
36+ apart run together, and each day is marked.
37+- **Live.** New messages, edits, deletions and reactions appear as they
38+ happen, and the line above the box says who is typing. Unread counts in
39+ the list and on the **Chat** tab move as messages arrive anywhere.
40+- **Sending** shows your message at once. If it doesn't reach g1t, it
41+ stays, marked **Not sent**: tap it to try again.
42+- **Agents' cards** work: their buttons (approve more budget, stop a
43+ session, file a draft issue) act from the phone, asking first or asking
44+ for an amount when the card does, and the card updates in place.
45+- **Long-press a message** to react, reply in a thread, copy it, or edit
46+ or delete your own. Tap a reaction to add yours or take it back.
47+- **Threads** open from a message's replies, with the message they are
48+ under at the top.
49+- **⋯** on a conversation stars, mutes or leaves it.
50+- **New message** (the pen at the top of Chat) messages a person or an
51+ agent, or a few of them together, or makes a channel. **Browse** at the
52+ foot of the list joins public channels.
53+
54+A notification for a conversation you have open is not shown; one for
55+another shows as a banner at the top of the screen.
56+
57+## Notifications
58+
59+The phone gets the same notifications as [browser push](/guides/notifications/#on-your-phone):
60+messages to you, mentions, replies in threads you are in, and agents
61+waiting on you, under the same rules. Nothing comes while you are using
62+g1t in a browser or in the app itself, and nothing in
63+[Do not disturb](/guides/chat/#sounds-and-do-not-disturb). Tapping one
64+opens the conversation or thread it is about.
65+
66+The app asks to notify you the first time you sign in. To change it
67+later, use **More → Notifications on this phone**, or the phone's own
68+settings for g1t, where Android lists the **Messages** channel. Signing
69+out stops them on that phone.
70+
71+## Signing in
72+
73+**Sign in with g1t** opens g1t.sh in your phone's browser, where you sign
74+in as usual (with your password, GitHub, two-factor) and approve the app.
75+The browser sends you back to the app, signed in. The app never sees your
76+password. This is [OAuth](/guides/authentication/#signing-in-with-oauth):
77+the app is listed as **g1t for Android** or **g1t for iOS** in
78+[Settings → Connected applications](https://g1t.sh/settings/applications),
79+where **Change access** narrows what it can do and **Sign out** ends it at
80+once.
81+
82+The approval page lists what the app asks for: reading your account,
83+workspaces, repositories, checks and workflows, reading and writing
84+issues, pull requests and notifications, recalling memory, reading
85+artifacts, and putting agents to work. Untick what you would rather it
86+could not do.
87+
88+**Use an access token** signs in with a
89+[personal access token](/guides/authentication/#access-tokens) you paste
90+in instead. The token stays on your phone, in its keystore.
91+
92+The app asks for `chat:read` and `chat:write` too. They let it use
93+Chat's routes and live connections on the site, and register for
94+notifications, and nothing else there: the app's sign-in cannot open the
95+site's pages as you.
96+
97+## Links
98+
99+A `g1t://` link opens the app, as it opens [the desktop app](/guides/desktop/#links):
100+`g1t://acme/-/chat/dm/chn_1` is that conversation. The app shows the
101+pages it has screens for:
102+
103+| Link | Opens |
104+| --- | --- |
105+| `g1t://<workspace>/-/chat`, `…/-/chat/<channel>`, `…/-/chat/dm/<id>` | Chat, or the conversation, in that workspace |
106+| `g1t://<owner>/<repo>` | The repository |
107+| `g1t://<owner>/<repo>/pull/<number>`, `…/issues/<number>` | The pull request or issue |
108+| `g1t://<workspace>/-/marketplace…`, `…/-/agents/skills` | Customize |
109+
110+Any other page opens on the site, in the browser.
111+
112+## Your own g1t
113+
114+If you [run g1t yourself](/guides/self-hosting/), choose **Server** on the
115+sign-in screen and enter your site's address, such as
116+`https://g1t.example.com`. The app takes the API to be `api.` in front of
117+it (`https://api.g1t.example.com`); if yours is elsewhere, enter it under
118+**API**. **Use g1t.sh** goes back.
119+
120+## What's not there yet
121+
122+- **Store releases.** Google Play first, then the App Store.
123+- **Home, Notifications, Agents and Artifacts** as tabs of their own.
124+ Their pages open on the site meanwhile.
125+- **Formatting in Code.** Descriptions and comments show their Markdown
126+ as written; messages in Chat are formatted.
127+- **Attachments and custom emoji.** Files in messages, and a
128+ workspace's own `:emoji:`, show as their names.
129+- **Replying from a notification**, without opening the app.
130+- **https links.** `g1t.sh` addresses open the site in the browser, not
131+ the app, until g1t.sh publishes the files Android and iOS check first.
132+
133+## For operators: building and releasing
134+
135+The app lives in `apps/mobile`: [Expo](https://expo.dev) with Expo
136+Router, in TypeScript, sharing types with the site through
137+`@g1t/contracts`. It builds with EAS Build. `apps/mobile/README.md` has
138+the commands for running it on a phone and the first-time setup.
139+
140+| Build | Command | What it makes |
141+| --- | --- | --- |
142+| Development | `eas build --profile development --platform android` | An APK of `g1t (dev)` (`sh.g1t.app.dev`) that loads its code from your computer. |
143+| Preview | `npm run build:android:apk -w @g1t/mobile` | An APK of `g1t (beta)` (`sh.g1t.app.preview`) to install on testers' phones. |
144+| Production | `npm run build:android -w @g1t/mobile` | An Android App Bundle of `g1t` (`sh.g1t.app`) for Google Play. |
145+
146+The three install side by side. `eas submit --platform android` sends
147+the latest production build to Google Play's internal testing track as a
148+draft.
+18−1
179179 The foot of each email says why you got it, and links to
180180 [g1t.sh/settings/notifications](https://g1t.sh/settings/notifications).
181181
182+## On your phone
183+
184+[The phone app](/guides/mobile/) gets the same notifications as
185+[browser notifications](/guides/chat/#notifications), under the same
186+rules:
187+
188+- only what you chose under **Notify me about**, for each workspace;
189+- never while **Do not disturb** is on;
190+- nothing from a conversation you [muted](/guides/chat/#star-and-mute),
191+ but a direct message or a mention of you;
192+- nothing while a g1t tab is in front of you: it tells you there.
193+
194+Your phone is registered when you allow notifications in the app.
195+**Send a test notification** reaches it too. Signing out of the app stops
196+them on that phone. g1t pushes to your 10 most recent phones, and stops
197+on its own for a phone the app was removed from.
198+
182199 ## Settings
183200
184201 **Settings → Notifications**, at
188205 | Setting | What it does | Default |
189206 | --- | --- | --- |
190207 | **Sounds**, **Sound set**, **Volume**, one switch per sound | What chat sounds like; see [Sounds and do not disturb](/guides/chat/#sounds-and-do-not-disturb). | On, Soft, 60, all but Sent |
191−| **Do not disturb** | Silences sounds, pop-ups and browser notifications until a time you choose. | Off |
208+| **Do not disturb** | Silences sounds, pop-ups, and browser and phone notifications until a time you choose. | Off |
192209 | **Desktop notifications** | A system notification from an open tab for a message to you while the window is behind another. In [the desktop app](/guides/desktop/) it is on, and the app shows them itself. | Off |
193210 | **Notify me about**, **Browser notifications** | What pops up and is pushed; see [chat notifications](/guides/chat/#notifications). | DMs and mentions; off |
194211 | **Email** | One checkbox per reason: you are also emailed when you are told of something for it. | agent waiting, review requested, mentioned |
+1−1
6464 | `services/search` | Runs: site search is SQLite full-text search |
6565 | `services/chat` | Runs, with its Durable Objects; custom emoji are kept with avatars |
6666 | `services/artifacts` | Runs: [artifacts](/guides/artifacts/) and their spaces, with their live rooms. Files people add are kept in the `g1t-docs-files` bucket. Search matches words, not meaning, because there is no embedding model. |
67−| `services/notify` | Runs. Notifications are live in open tabs; browser push needs a VAPID key pair, which an installation does not make. |
67+| `services/notify` | Runs. Notifications are live in open tabs; browser push needs a VAPID key pair, which an installation does not make. Push to the phone app goes through Expo and needs nothing set. |
6868 | `services/agents` | Runs, but replies need the model proxy, which is off, so an agent answers with a short apology |
6969 | `services/billing` | Runs with nothing charged and no usage limit |
7070 | `services/integrations` | Runs |
+2−2
753753
754754 | | What it does |
755755 | --- | --- |
756−| **The bar along the bottom** | **Home**, **Chat**, **Notifications**, **Agents** and **Code** (for members with Code access), each with what is unread, and **More**. It steps aside while the keyboard is up and inside a conversation. |
756+| **The bar along the bottom** | **Home**, **Chat**, **Notifications**, **Agents** and **Code** (for members with Code access), each with what is unread, and **More**. It runs flat along the bottom edge, as the rail stands beside the page on a computer, and the page fills the screen above it. The app you are in is marked as on the rail. It steps aside while the keyboard is up and inside a conversation. |
757757 | **More** | A panel above the bar with **All apps**, the [Marketplace](/guides/marketplace/), your pinned apps, **Artifacts**, **People**, **Workspace**, and **You and help**: your status, profile and settings, [appearance](#appearance), the documentation, support, status, the keyboard's shortcuts and signing out. |
758−| **The sidebar button**, at the left of the page's header | Opens the sidebar of the app you are in from the left: the same lists and links as on a computer. Tap outside it, or open a page, and it closes. |
758+| **The sidebar button**, at the left of the page's header | Opens the sidebar of the app you are in from the left, most of the screen wide: the same lists and links as on a computer. Tap outside it, or open a page, and it closes. |
759759 | **The tab you are already on** | Tap it again to open that app's sidebar too. |
760760 | **The workspace's name** in the page's header | Switches workspace, or opens your profile or settings. |
761761
+43−0
1+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2+
3+# dependencies
4+node_modules/
5+
6+# Expo
7+.expo/
8+dist/
9+web-build/
10+expo-env.d.ts
11+
12+# Native
13+.kotlin/
14+*.orig.*
15+*.jks
16+*.p8
17+*.p12
18+*.key
19+*.mobileprovision
20+
21+# Metro
22+.metro-health-check*
23+
24+# debug
25+npm-debug.*
26+yarn-debug.*
27+yarn-error.*
28+
29+# macOS
30+.DS_Store
31+*.pem
32+
33+# local env files
34+.env*.local
35+
36+# typescript
37+*.tsbuildinfo
38+
39+
40+# generated native folders
41+/ios
42+/android
43+google-services.json
+235−0
1+# g1t for Android and iOS
2+
3+The phone app: [Expo](https://expo.dev) (SDK 57, React Native 0.86) with
4+Expo Router, in TypeScript. Chat, Code and Customize, signed in with g1t's
5+own OAuth. Android is the first target. What it does for people is in the
6+guide, [The phone app](../docs/src/content/docs/guides/mobile.md).
7+
8+## Layout
9+
10+```text
11+app.config.ts name, ids and icons per build variant (APP_VARIANT)
12+eas.json EAS Build profiles: development, preview, production
13+metro.config.js one React for the app in the monorepo
14+scripts/icons.mjs draws the icons from packages/theme/mark.svg
15+src/
16+ app/ the screens (Expo Router: one file, one route)
17+ sign-in.tsx Sign in with g1t, an access token, or another server
18+ oauth/callback.tsx where the site sends sign-in back (g1t://oauth/callback)
19+ (app)/ signed in: the tabs
20+ chat/ the list, a conversation, a thread, new, browse
21+ code/ repositories, pull requests, issues, diffs
22+ more/ Customize, notifications, workspaces, sign out
23+ customize/ Skills, Connectors, Plugins (from More)
24+ home/, notifications/, agents/ coming: they open the site
25+ +native-intent.tsx g1t:// links to screens (lib/links.ts)
26+ components/ the building blocks, themed (ui.tsx); chat/ and Code's
27+ lib/
28+ server.ts which g1t: site and API addresses
29+ oauth.ts OAuth 2.1 with PKCE against the server's metadata
30+ credentials.ts the server and credentials, in the phone's keystore
31+ client.ts every request: refresh, errors, the site or the API
32+ session.tsx who is signed in, to which workspace
33+ chat.ts, code.ts the data for Chat and Code (TanStack Query)
34+ live.ts the site's live sockets, with tickets, kept open
35+ feed.tsx counts, banners and presence while the app is open
36+ push.ts notifications on the phone
37+ customize.ts Customize's listings, from @g1t/contracts' catalogs
38+ theme/ packages/theme/tokens.css's colours, for React Native
39+```
40+
41+Shared types and catalogs come from `@g1t/contracts` (by subpath, such as
42+`@g1t/contracts/oauth`, so the app does not bundle every module).
43+Customize's three parts are defined there too (`customize.ts`), so the
44+site can move the Marketplace onto the same model.
45+
46+## How it talks to g1t
47+
48+| What | Where | How it is signed in |
49+| --- | --- | --- |
50+| Who you are, your workspaces | `GET {api}/user` | `Authorization: Bearer g1t_…` |
51+| Code | `{api}/repos…` REST | the same |
52+| Chat | `{site}/<workspace>/-/chat/api` and its live socket, as the site's own pages use them | the same header, with `chat:*` ([below](#chat-with-the-apps-sign-in)); a socket gets a ticket from `{site}/-/live/ticket` first |
53+| Notifications | `{site}/-/notify` and the feed socket `{site}/-/live` | the same, with `notifications:*` |
54+| Sign-in | `{api}/.well-known/oauth-authorization-server`, `{site}/oauth/authorize`, `{api}/oauth/token` | OAuth 2.1, PKCE (S256), public client |
55+
56+The OAuth client is not registered anywhere: its id encodes its name and
57+redirect (`encodeOAuthClient`), so `g1t for Android` with
58+`g1t://oauth/callback` is the same client on every phone. Access tokens
59+last 30 days and are refreshed a minute before they expire; a refresh
60+token works once, so `lib/client.ts` shares one refresh between
61+requests.
62+
63+### Chat with the app's sign-in
64+
65+Chat has no REST API: the app uses the site's own chat routes. The app
66+asks for `chat:read`, `chat:write`, `notifications:read` and
67+`notifications:write`, and the site takes a token holding them by name
68+(OAuth or personal, without "Use the website as you") on these alone
69+(`apps/web/app/lib/website-token.ts`, `scopedRoute` and `socketUser`):
70+
71+| Route | Needs |
72+| --- | --- |
73+| `GET`, `POST /<ws>/-/chat/api` | `chat:read`, `chat:write` |
74+| `GET /<ws>/-/chat/person/<username>` | `chat:read` |
75+| `GET /-/live/ticket` for `/<ws>/-/chat/live` and `/-/live` | `chat:read` |
76+| `GET`, `POST /-/notify` | `notifications:read`, `notifications:write` |
77+
78+A full-access token does not count: it reaches Chat only with the website
79+permission.
80+
81+### Live, and notifications
82+
83+- **A conversation's socket** (`lib/chat.ts`, `useChannelLive`) brings new,
84+ changed and deleted messages, reactions and typing, and takes the
85+ app's own typing.
86+- **The feed** (`lib/feed.tsx`), the socket every tab of the site keeps,
87+ is open while the app is in front: unread counts, notifications (a
88+ banner, `components/chat/banner.tsx`) and presence. It tells g1t the app
89+ is focused, so nothing is pushed to the phone while it is in use; in the
90+ background it closes.
91+- **Push** (`lib/push.ts`): the app registers its Expo push token with
92+ `POST /-/notify` `{ intent: "register_device", device: { token, platform, name } }`
93+ and takes it back on signing out (`unregister_device`). The notify
94+ service sends through Expo's push service on the Android channel
95+ `messages`, with `data` `{ href, workspace, kind, tag, channel_id, thread_root, card }`;
96+ tapping one opens its thread or conversation.
97+
98+## Run it
99+
100+You need Node 22 (the repository's).
101+
102+### In Expo Go (quickest)
103+
104+Everything the app uses is in Expo Go for SDK 57, so no native build is
105+needed:
106+
107+```sh
108+npm install # at the repository's root: one workspace
109+cd apps/mobile
110+npx expo start --go # then scan the QR code with Expo Go
111+```
112+
113+Install [Expo Go](https://expo.dev/go) on the phone, or press `a` for an
114+Android emulator. The phone and the computer must be on the same network
115+(or use `npx expo start --tunnel`).
116+
117+Signing in works in Go: the site sends you back to Go's own address,
118+`exp://<your computer>:8081/--/oauth/callback`, which g1t accepts as an
119+application's own scheme. The approval page and Connected applications
120+call it **g1t in Expo Go**. Its client id encodes that address, so a new
121+network makes a new client: the app keeps the id it signed in with for
122+refreshing, and signing in again after a move just adds a new entry to
123+Connected applications. Chat works in Go in full, live, with banners
124+while the app is open. What Go can't do: notifications on an Android
125+phone (Expo took push out of Go there), open from `g1t://` links (they go
126+to an installed build), and show the app's own name and icon.
127+
128+To develop against a g1t running on your computer, choose **Server** on
129+the sign-in screen and give its address on your network, such as
130+`http://192.168.1.20:5173` and the API's `http://192.168.1.20:8789`
131+(`localhost` on a phone is the phone). Run that g1t with its `SITE_URL`
132+and `API_URL` set to those addresses too: the API's OAuth metadata
133+names the sign-in page by `SITE_URL`, and the phone opens what it names.
134+
135+### In a development build
136+
137+For `g1t://` links, the app's own icon and name, and anything native Go
138+lacks later (push notifications):
139+
140+```sh
141+npm run build:android:dev # EAS builds g1t (dev) in the cloud: install it from the link
142+npx expo start # then open g1t (dev) on the phone; it finds this computer
143+```
144+
145+The development build is `g1t (dev)` (`sh.g1t.app.dev`), with
146+`expo-dev-client`: it loads the app's code from `npx expo start` on your
147+computer, signs in through `g1t://oauth/callback`, and gets notifications
148+once Firebase is set up ([below](#push-notifications-on-android)). Build
149+it again only when a native module or `app.config.ts` changes; code
150+changes reload live. With Android Studio, `APP_VARIANT=development npx expo
151+run:android` builds the same app on your computer instead (it makes
152+`android/`, ignored by git).
153+
154+Checks, which CI runs from the root (`npm run typecheck`, `npm test`):
155+
156+```sh
157+npm run typecheck -w @g1t/mobile
158+npm test -w @g1t/mobile # lib/*.test.ts under Node
159+```
160+
161+## Set up EAS and ship Android
162+
163+Once, by someone with access to the Expo account and Google Play:
164+
165+1. `npm install -g eas-cli`, then `eas login`.
166+2. The EAS project exists (`35fb457b-…`, `EAS_PROJECT` in
167+ `app.config.ts`); `eas whoami` should be an account with access to it.
168+ Set `EAS_OWNER` in your shell if it belongs to an organization. On
169+ expo.dev, the project's GitHub settings take `apps/mobile` as the base
170+ directory.
171+3. `eas build --profile preview --platform android`. EAS makes and keeps
172+ the upload keystore the first time; say yes. The build ends with a link
173+ and a QR code to install the APK.
174+4. In the [Google Play Console](https://play.google.com/console), create
175+ the app with the package `sh.g1t.app`, and turn on Play App Signing.
176+5. `npm run build:android` makes the production App Bundle. Upload the
177+ first one by hand on Play's **Internal testing** track: Google requires
178+ that before its API accepts uploads.
179+6. For `eas submit`, make a Google Cloud service account with access to
180+ the app in the Play Console, download its JSON key, and add it to the
181+ EAS project's credentials (`eas credentials`). From then on,
182+ `eas submit --platform android --latest` sends a build to internal
183+ testing as a draft (`eas.json`'s `submit.production`).
184+
185+iOS is the same with `--platform ios`, an Apple Developer account and the
186+bundle id `sh.g1t.app`; EAS makes the certificates.
187+
188+### Push notifications on Android
189+
190+Expo's push service sends to Android through Firebase Cloud Messaging,
191+which needs a Firebase project. Once:
192+
193+1. In the [Firebase console](https://console.firebase.google.com), make a
194+ project, and in it an Android app for each package: `sh.g1t.app`,
195+ `sh.g1t.app.preview` and `sh.g1t.app.dev`. Download
196+ `google-services.json` (one file lists all three).
197+2. Give it to EAS as a file variable:
198+ `eas env:create --scope project --name GOOGLE_SERVICES_JSON --type file --value ./google-services.json --visibility secret --environment production --environment preview --environment development`.
199+ `app.config.ts` reads it from there, or a `google-services.json` beside
200+ it locally (ignored by git).
201+3. In Firebase, **Project settings → Service accounts → Generate new private
202+ key**, then `eas credentials` → Android → the production build's package →
203+ **Google Service Account → Push Notifications (FCM V1)**, and upload that
204+ key. Repeat for `sh.g1t.app.preview` and `sh.g1t.app.dev`.
205+4. Build again. Pushes need a build: Expo Go on Android has none.
206+
207+`EXPO_ACCESS_TOKEN` on the notify Worker is needed only if the Expo
208+project turns on **Enhanced push security**.
209+
210+### Before the first store release
211+
212+- **App Links**: `https://g1t.sh/.well-known/assetlinks.json` naming
213+ `sh.g1t.app` and Play's signing certificate's SHA-256, then an
214+ `intentFilters` entry with `autoVerify` in `app.config.ts`, so g1t.sh
215+ links open the app. iOS needs `apple-app-site-association` the same way.
216+- **The brand's faces**: TTF copies of Hanken Grotesk, Bricolage
217+ Grotesque and IBM Plex Mono (packages/theme ships woff2), loaded with
218+ `expo-font`.
219+- **Agents' faces**: `components/ui.tsx`'s `Avatar` draws a letter for an
220+ agent; the site's `AgentFace` should be shared and drawn here.
221+- **The store listing**: screenshots, a privacy policy URL, and Play's
222+ data safety form (the app keeps the server and credentials in the
223+ keystore, registers a push token with the g1t it is pointed at, and
224+ sends nothing anywhere else).
225+
226+## Notes
227+
228+- **React is pinned.** The site uses a newer React than React Native 0.86
229+ allows, so the app has its own `react` (19.2.3) and `metro.config.js`
230+ resolves every `react` import to it. `expo-doctor` reports the root's
231+ copy as a duplicate; that is expected here, and the bundle has one.
232+- **Icons** are drawn from the mark: `npm run icons -w @g1t/mobile`.
233+- **Versions**: `version` in `app.config.ts` is what people see;
234+ EAS counts build numbers itself (`appVersionSource: remote`). Over-the-air
235+ updates go to builds of the same `version` (`runtimeVersion`).
+103−0
1+/// <reference types="node" />
2+
3+import { existsSync } from "node:fs";
4+import { join } from "node:path";
5+
6+import type { ConfigContext, ExpoConfig } from "expo/config";
7+
8+/**
9+ * The phone app's configuration. `APP_VARIANT` (set by eas.json's
10+ * profiles) picks an identity, so a development build, a preview build
11+ * and the store build install side by side on one phone:
12+ *
13+ * | Variant | Android package / iOS bundle | Name |
14+ * | ------------- | ---------------------------- | ----------- |
15+ * | `development` | `sh.g1t.app.dev` | g1t (dev) |
16+ * | `preview` | `sh.g1t.app.preview` | g1t (beta) |
17+ * | production | `sh.g1t.app` | g1t |
18+ *
19+ * The EAS project is g1t's on expo.dev; push notifications on Android
20+ * also need the Firebase credentials uploaded there (README.md).
21+ */
22+
23+/** The EAS project's id, from `eas init`. `EAS_PROJECT_ID` overrides it. */
24+const EAS_PROJECT = "35fb457b-905c-44cd-9b35-0899e12852dd";
25+
26+const variant = process.env.APP_VARIANT;
27+const suffix = variant === "development" ? ".dev" : variant === "preview" ? ".preview" : "";
28+const name = variant === "development" ? "g1t (dev)" : variant === "preview" ? "g1t (beta)" : "g1t";
29+const id = `sh.g1t.app${suffix}`;
30+const projectId = process.env.EAS_PROJECT_ID ?? EAS_PROJECT;
31+
32+/** g1t's background, dark as the site is by default (packages/theme/tokens.css `--g1t-bg`). */
33+const BACKGROUND = "#0f0f11";
34+/** The accent, for the notification icon's tint (`--g1t-accent`). */
35+const ACCENT = "#b6a8ff";
36+
37+/**
38+ * Firebase's file for the Android app, which push on Android needs: from
39+ * an EAS file secret in the cloud (`GOOGLE_SERVICES_JSON`), or a copy
40+ * beside this file locally (ignored by git). Without one, everything but
41+ * push works.
42+ */
43+const googleServices = process.env.GOOGLE_SERVICES_JSON ?? (existsSync(join(__dirname, "google-services.json")) ? "./google-services.json" : undefined);
44+
45+export default ({ config }: ConfigContext): ExpoConfig => ({
46+ ...config,
47+ name,
48+ slug: "g1t",
49+ owner: process.env.EAS_OWNER,
50+ version: "0.1.0",
51+ orientation: "portrait",
52+ icon: "./assets/images/icon.png",
53+ // g1t:// links, as on the desktop, and where sign-in comes back to.
54+ scheme: "g1t",
55+ userInterfaceStyle: "automatic",
56+ backgroundColor: BACKGROUND,
57+ ios: {
58+ bundleIdentifier: id,
59+ supportsTablet: true,
60+ config: { usesNonExemptEncryption: false },
61+ },
62+ android: {
63+ package: id,
64+ adaptiveIcon: {
65+ backgroundColor: BACKGROUND,
66+ foregroundImage: "./assets/images/android-icon-foreground.png",
67+ monochromeImage: "./assets/images/android-icon-monochrome.png",
68+ },
69+ predictiveBackGestureEnabled: true,
70+ ...(googleServices ? { googleServicesFile: googleServices } : {}),
71+ softwareKeyboardLayoutMode: "resize",
72+ },
73+ plugins: [
74+ "expo-router",
75+ "expo-secure-store",
76+ "expo-web-browser",
77+ [
78+ "expo-notifications",
79+ {
80+ icon: "./assets/images/notification-icon.png",
81+ color: ACCENT,
82+ defaultChannel: "messages",
83+ },
84+ ],
85+ [
86+ "expo-splash-screen",
87+ {
88+ backgroundColor: BACKGROUND,
89+ image: "./assets/images/splash-icon.png",
90+ imageWidth: 96,
91+ },
92+ ],
93+ ],
94+ experiments: {
95+ reactCompiler: true,
96+ },
97+ runtimeVersion: { policy: "appVersion" },
98+ ...(projectId ? { updates: { url: `https://u.expo.dev/${projectId}` } } : {}),
99+ extra: {
100+ ...config.extra,
101+ ...(projectId ? { eas: { projectId } } : {}),
102+ },
103+});
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+36−0
1+{
2+ "cli": {
3+ "version": ">= 16.0.0",
4+ "appVersionSource": "remote"
5+ },
6+ "build": {
7+ "base": {
8+ "node": "22.22.0"
9+ },
10+ "development": {
11+ "extends": "base",
12+ "developmentClient": true,
13+ "distribution": "internal",
14+ "env": { "APP_VARIANT": "development" },
15+ "android": { "buildType": "apk" }
16+ },
17+ "preview": {
18+ "extends": "base",
19+ "distribution": "internal",
20+ "channel": "preview",
21+ "env": { "APP_VARIANT": "preview" },
22+ "android": { "buildType": "apk" }
23+ },
24+ "production": {
25+ "extends": "base",
26+ "channel": "production",
27+ "autoIncrement": true,
28+ "android": { "buildType": "app-bundle" }
29+ }
30+ },
31+ "submit": {
32+ "production": {
33+ "android": { "track": "internal", "releaseStatus": "draft" }
34+ }
35+ }
36+}
+20−0
1+// Metro in the g1t monorepo. Expo finds the workspace root and
2+// @g1t/contracts by itself; what it needs told is that React comes from
3+// this app alone. The site pins a newer React at the root, and React
4+// Native must run with the exact React it was built for (19.2.3 with
5+// Expo SDK 57), so `react` and its subpaths resolve from here wherever
6+// they are imported, including from packages hoisted to the root.
7+const path = require("node:path");
8+const { getDefaultConfig } = require("expo/metro-config");
9+
10+const config = getDefaultConfig(__dirname);
11+const here = path.join(__dirname, "package.json");
12+
13+const resolveRequest = config.resolver.resolveRequest;
14+config.resolver.resolveRequest = (context, moduleName, platform) => {
15+ const pinned = moduleName === "react" || moduleName.startsWith("react/");
16+ const from = pinned ? { ...context, originModulePath: here } : context;
17+ return resolveRequest ? resolveRequest(from, moduleName, platform) : context.resolveRequest(from, moduleName, platform);
18+};
19+
20+module.exports = config;
+59−0
1+{
2+ "name": "@g1t/mobile",
3+ "private": true,
4+ "version": "0.1.0",
5+ "description": "The g1t phone app (Expo): Chat and Code first, signed in with OAuth, for Android and iOS.",
6+ "main": "expo-router/entry",
7+ "scripts": {
8+ "start": "expo start",
9+ "start:go": "expo start --go",
10+ "android": "expo run:android",
11+ "ios": "expo run:ios",
12+ "prebuild": "expo prebuild --clean",
13+ "typecheck": "tsc --noEmit",
14+ "test": "node --test \"src/lib/*.test.ts\"",
15+ "doctor": "expo-doctor",
16+ "icons": "node scripts/icons.mjs",
17+ "build:android:dev": "eas build --platform android --profile development",
18+ "build:android": "eas build --platform android --profile production",
19+ "build:android:apk": "eas build --platform android --profile preview",
20+ "build:ios": "eas build --platform ios --profile production"
21+ },
22+ "dependencies": {
23+ "@g1t/contracts": "*",
24+ "@tanstack/react-query": "^5.104.1",
25+ "expo": "~57.0.27",
26+ "expo-auth-session": "~57.0.14",
27+ "expo-clipboard": "~57.0.2",
28+ "expo-constants": "~57.0.21",
29+ "expo-crypto": "~57.0.3",
30+ "expo-dev-client": "~57.0.19",
31+ "expo-device": "~57.0.2",
32+ "expo-font": "~57.0.4",
33+ "expo-haptics": "~57.0.3",
34+ "expo-image": "~57.0.5",
35+ "expo-linking": "~57.0.12",
36+ "expo-notifications": "~57.0.22",
37+ "expo-router": "~57.0.25",
38+ "expo-secure-store": "~57.0.4",
39+ "expo-splash-screen": "~57.0.9",
40+ "expo-status-bar": "~57.0.1",
41+ "expo-system-ui": "~57.0.4",
42+ "expo-web-browser": "~57.0.3",
43+ "lucide-react-native": "^1.55.0",
44+ "react": "19.2.3",
45+ "react-native": "0.86.3",
46+ "react-native-gesture-handler": "~2.32.0",
47+ "react-native-reanimated": "4.5.1",
48+ "react-native-safe-area-context": "~5.7.0",
49+ "react-native-screens": "~4.26.0",
50+ "react-native-svg": "15.15.4",
51+ "react-native-worklets": "0.10.1"
52+ },
53+ "devDependencies": {
54+ "@types/node": "^22.20.5",
55+ "@types/react": "~19.2.2",
56+ "sharp": "^0.35.5",
57+ "typescript": "~6.0.3"
58+ }
59+}
+35−0
1+// Draws the app's icons from the g1t mark (packages/theme/mark.svg):
2+// `npm run icons -w @g1t/mobile` after the mark or the background changes.
3+//
4+// - icon.png: the store and iOS icon, the mark on g1t's background.
5+// - android-icon-foreground.png: the adaptive icon's mark, inside the
6+// middle 66% Android keeps whatever the launcher's mask.
7+// - android-icon-monochrome.png: the same in white, for themed icons.
8+// - splash-icon.png: the mark alone, on the splash screen's background.
9+// - notification-icon.png: the mark in white on nothing, as Android draws
10+// a notification's small icon (tinted with the accent).
11+import { readFile } from "node:fs/promises";
12+import { fileURLToPath } from "node:url";
13+import sharp from "sharp";
14+
15+const here = (path) => fileURLToPath(new URL(path, import.meta.url));
16+const mark = await readFile(here("../../../packages/theme/mark.svg"), "utf8");
17+const BACKGROUND = "#0f0f11";
18+
19+/** The mark at `scale` of a `size` square, recoloured, on `background` or nothing. */
20+async function draw(file, { size, scale, color, background }) {
21+ const inner = Math.round(size * scale);
22+ const svg = Buffer.from(mark.replace(/#b6a8ff/g, color ?? "#b6a8ff").replace("<svg ", `<svg width="${inner}" height="${inner}" `));
23+ const glyph = await sharp(svg, { density: 72 * (inner / 80) }).resize(inner, inner).png().toBuffer();
24+ await sharp({ create: { width: size, height: size, channels: 4, background: background ?? { r: 0, g: 0, b: 0, alpha: 0 } } })
25+ .composite([{ input: glyph, gravity: "centre" }])
26+ .png()
27+ .toFile(here(`../assets/images/${file}`));
28+}
29+
30+await draw("icon.png", { size: 1024, scale: 0.62, background: BACKGROUND });
31+await draw("android-icon-foreground.png", { size: 1024, scale: 0.46 });
32+await draw("android-icon-monochrome.png", { size: 1024, scale: 0.46, color: "#ffffff" });
33+await draw("splash-icon.png", { size: 512, scale: 0.9 });
34+await draw("notification-icon.png", { size: 96, scale: 0.8, color: "#ffffff" });
35+console.log("Drew icon.png, android-icon-foreground.png, android-icon-monochrome.png, splash-icon.png and notification-icon.png.");
+133−0
1+import * as Notifications from "expo-notifications";
2+import { Tabs, router, useGlobalSearchParams, useSegments } from "expo-router";
3+import { Bell, Bot, Code2, House, Menu, MessagesSquare } from "lucide-react-native";
4+import type { LucideIcon } from "lucide-react-native";
5+import { type ComponentType, useEffect, useRef } from "react";
6+import { StyleSheet, View } from "react-native";
7+
8+import { Banner } from "@/components/chat/banner";
9+import { FeedProvider, useFeed } from "@/lib/feed";
10+import { routeFor } from "@/lib/links";
11+import { type PushData, registerForPush } from "@/lib/push";
12+import { useSignedIn } from "@/lib/session";
13+import { hasCodeAccess } from "@g1t/contracts";
14+import { useColors } from "@/theme";
15+
16+/**
17+ * The signed-in app. Its bar is the site's phone bar
18+ * (apps/web/app/components/mobile.tsx): Home, Chat, Notifications, Agents,
19+ * Code, and More. Chat and Code have their screens; the others say so and
20+ * open the site. Customize and your account are under More.
21+ */
22+export default function AppLayout() {
23+ return (
24+ <FeedProvider>
25+ <PushSetup />
26+ <AppTabs />
27+ <Banner />
28+ </FeedProvider>
29+ );
30+}
31+
32+function AppTabs() {
33+ const colors = useColors();
34+ const { workspace, selectWorkspace } = useSignedIn();
35+ const { unread, mentions } = useFeed();
36+ // A link names its workspace (`/chat?workspace=acme`, lib/links.ts): switch to it.
37+ const { workspace: linked } = useGlobalSearchParams<{ workspace?: string }>();
38+ useEffect(() => {
39+ if (linked && linked.toLowerCase() !== workspace?.slug.toLowerCase()) void selectWorkspace(linked);
40+ }, [linked, workspace?.slug, selectWorkspace]);
41+ const code = !workspace || hasCodeAccess(workspace);
42+ // A conversation or a thread takes the whole screen, as on the site: the bar steps aside.
43+ const segments = useSegments() as string[];
44+ const inConversation = segments[1] === "chat" && (segments[2] === "[channel]" || segments[2] === "thread");
45+ const badge = mentions > 0 ? mentions : unread > 0 ? unread : undefined;
46+
47+ return (
48+ <Tabs
49+ initialRouteName="chat"
50+ screenOptions={{
51+ headerShown: false,
52+ // The site's phone bar: flat along the bottom on the rail's colour, the
53+ // app you are in marked as the rail marks it (apps/web components/mobile.tsx).
54+ tabBarActiveTintColor: colors.fg,
55+ tabBarInactiveTintColor: colors.faint,
56+ tabBarStyle: inConversation
57+ ? { display: "none" }
58+ : { backgroundColor: colors.rail, borderTopColor: colors.line, borderTopWidth: StyleSheet.hairlineWidth, elevation: 0 },
59+ tabBarHideOnKeyboard: true,
60+ tabBarLabelStyle: { fontSize: 11, fontWeight: "500" },
61+ tabBarIconStyle: { width: 44, height: 28 },
62+ }}
63+ >
64+ <Tabs.Screen name="index" options={{ href: null }} />
65+ <Tabs.Screen name="home" options={{ title: "Home", tabBarIcon: (props) => <BarIcon icon={House} {...props} /> }} />
66+ <Tabs.Screen
67+ name="chat"
68+ options={{
69+ title: "Chat",
70+ tabBarIcon: (props) => <BarIcon icon={MessagesSquare} {...props} />,
71+ tabBarBadge: badge,
72+ tabBarBadgeStyle: { backgroundColor: mentions > 0 ? colors.accent : colors.lineStrong, color: mentions > 0 ? colors.onAccent : colors.fg, fontSize: 11 },
73+ }}
74+ />
75+ <Tabs.Screen name="notifications" options={{ title: "Notifications", tabBarIcon: (props) => <BarIcon icon={Bell} {...props} /> }} />
76+ <Tabs.Screen name="agents" options={{ title: "Agents", tabBarIcon: (props) => <BarIcon icon={Bot} {...props} /> }} />
77+ <Tabs.Screen name="code" options={{ title: "Code", href: code ? undefined : null, tabBarIcon: (props) => <BarIcon icon={Code2} {...props} /> }} />
78+ <Tabs.Screen name="customize" options={{ href: null }} />
79+ <Tabs.Screen name="more" options={{ title: "More", tabBarIcon: (props) => <BarIcon icon={Menu} {...props} /> }} />
80+ </Tabs>
81+ );
82+}
83+
84+/** A tab's icon: on the rail's accent in a 10px-rounded square when it is the tab you are in. */
85+function BarIcon({ icon: Icon, focused }: { icon: LucideIcon | ComponentType<{ color: string; size: number }>; focused: boolean }) {
86+ const colors = useColors();
87+ return (
88+ <View style={{ width: 44, height: 28, borderRadius: 10, alignItems: "center", justifyContent: "center", backgroundColor: focused ? colors.railAccent : "transparent" }}>
89+ <Icon color={focused ? colors.fg : colors.muted} size={20} />
90+ </View>
91+ );
92+}
93+
94+/**
95+ * Registers the phone for notifications once signed in (asking the first
96+ * time), and opens what a tapped notification is about: the thread or
97+ * conversation, in its workspace.
98+ */
99+function PushSetup() {
100+ const { client, selectWorkspace } = useSignedIn();
101+ const handled = useRef<string | null>(null);
102+ const response = Notifications.useLastNotificationResponse();
103+
104+ useEffect(() => {
105+ void registerForPush(client, true).catch((error: unknown) => console.warn("g1t: registering for notifications failed", error));
106+ // The system can give the app a new push token: register that one too.
107+ const changed = Notifications.addPushTokenListener(() => {
108+ void registerForPush(client, false).catch(() => undefined);
109+ });
110+ return () => changed.remove();
111+ }, [client]);
112+
113+ useEffect(() => {
114+ if (!response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return;
115+ const id = response.notification.request.identifier;
116+ if (handled.current === id) return;
117+ handled.current = id;
118+ const data = (response.notification.request.content.data ?? {}) as PushData;
119+ void (async () => {
120+ if (data.workspace) await selectWorkspace(data.workspace);
121+ if (data.channel_id && data.thread_root) {
122+ router.push({ pathname: "/chat/thread", params: { channel: data.channel_id, root: data.thread_root } });
123+ } else if (data.channel_id) {
124+ router.push({ pathname: "/chat/[channel]", params: { channel: data.channel_id } });
125+ } else if (data.href) {
126+ const route = routeFor(data.href);
127+ router.push((route ?? `/open?path=${encodeURIComponent(data.href)}`) as never);
128+ }
129+ })();
130+ }, [response, selectWorkspace]);
131+
132+ return null;
133+}
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+15−0
1+import { Bot } from "lucide-react-native";
2+
3+import { ComingSoon } from "@/components/coming-soon";
4+
5+/** Agents: the workspace's agents, their sessions, budgets and memory. */
6+export default function Agents() {
7+ return (
8+ <ComingSoon
9+ title="Agents"
10+ icon={Bot}
11+ body="Your workspace's agents, what they are doing and what they cost. Message any of them from Chat meanwhile."
12+ path={(workspace) => `/${workspace}/-/agents`}
13+ />
14+ );
15+}
+100−0
1+import { Stack, router, useLocalSearchParams } from "expo-router";
2+import { BellOff, Bell, LogOut, MoreHorizontal, Star, StarOff } from "lucide-react-native";
3+import { useState } from "react";
4+import { Alert, Pressable, View } from "react-native";
5+
6+import { Conversation } from "@/components/chat/conversation";
7+import { Sheet, SheetAction } from "@/components/chat/sheet";
8+import { Empty, Loading, Screen, Text } from "@/components/ui";
9+import { useChatActions, useChatSidebar } from "@/lib/chat";
10+import { space, useColors } from "@/theme";
11+
12+/**
13+ * A conversation. `channel` is a channel's id, or a channel's name when a
14+ * link named it (`/acme/-/chat/general`). The ⋯ button stars, mutes or
15+ * leaves it.
16+ */
17+export default function ConversationScreen() {
18+ const params = useLocalSearchParams<{ channel: string }>();
19+ const sidebar = useChatSidebar();
20+ const colors = useColors();
21+ const [menu, setMenu] = useState(false);
22+ const entry = sidebar.data?.entries.find((one) => one.channel.id === params.channel || one.channel.name === params.channel);
23+ // A link by name waits for the sidebar to say which channel it is.
24+ const channelId = entry?.channel.id ?? (/^[a-z]+_/.test(params.channel) ? params.channel : "");
25+ const actions = useChatActions(channelId);
26+ const dm = entry?.channel.kind === "dm";
27+ const title = entry ? (dm ? entry.title : `#${entry.channel.name}`) : "";
28+
29+ if (!channelId) {
30+ return (
31+ <Screen>
32+ <Stack.Screen options={{ title: "" }} />
33+ {sidebar.isPending ? <Loading /> : <Empty title="Not in this conversation" body="It may be private, or you may need to join it first." />}
34+ </Screen>
35+ );
36+ }
37+
38+ return (
39+ <Screen>
40+ <Stack.Screen
41+ options={{
42+ headerTitle: () => (
43+ <View>
44+ <Text style={{ fontWeight: "700" }} numberOfLines={1}>
45+ {title}
46+ </Text>
47+ {entry?.channel.topic ? (
48+ <Text size="small" tone="muted" numberOfLines={1}>
49+ {entry.channel.topic}
50+ </Text>
51+ ) : null}
52+ </View>
53+ ),
54+ headerRight: () =>
55+ entry ? (
56+ <Pressable accessibilityLabel="Conversation options" hitSlop={10} onPress={() => setMenu(true)}>
57+ <MoreHorizontal size={22} color={colors.fg} />
58+ </Pressable>
59+ ) : null,
60+ }}
61+ />
62+ <Conversation channelId={channelId} thread={null} title={title || "this conversation"} />
63+ <Sheet visible={menu} onClose={() => setMenu(false)}>
64+ <View style={{ paddingHorizontal: space.lg, paddingBottom: space.sm }}>
65+ <Text size="heading">{title}</Text>
66+ </View>
67+ <SheetAction
68+ icon={entry?.starred ? StarOff : Star}
69+ label={entry?.starred ? "Unstar" : "Star"}
70+ onPress={() => {
71+ setMenu(false);
72+ actions.preferences.mutate({ starred: !entry?.starred });
73+ }}
74+ />
75+ <SheetAction
76+ icon={entry?.muted ? Bell : BellOff}
77+ label={entry?.muted ? "Unmute" : "Mute"}
78+ onPress={() => {
79+ setMenu(false);
80+ actions.preferences.mutate({ muted: !entry?.muted });
81+ }}
82+ />
83+ {!dm ? (
84+ <SheetAction
85+ icon={LogOut}
86+ label="Leave channel"
87+ danger
88+ onPress={() => {
89+ setMenu(false);
90+ Alert.alert(`Leave ${title}?`, "You can join it again from Browse.", [
91+ { text: "Cancel", style: "cancel" },
92+ { text: "Leave", style: "destructive", onPress: () => actions.leave.mutate(undefined, { onSuccess: () => router.back() }) },
93+ ]);
94+ }}
95+ />
96+ ) : null}
97+ </Sheet>
98+ </Screen>
99+ );
100+}
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+59−0
1+import { Stack, router } from "expo-router";
2+import { Hash } from "lucide-react-native";
3+import { FlatList, RefreshControl } from "react-native";
4+
5+import { Badge, Button, Empty, ErrorView, Loading, Row, Screen } from "@/components/ui";
6+import { useBrowseChannels, useChatSidebar, useStartConversation } from "@/lib/chat";
7+import { useColors } from "@/theme";
8+
9+/** The workspace's public channels: open one you are in, or join one you are not. */
10+export default function Browse() {
11+ const channels = useBrowseChannels();
12+ const sidebar = useChatSidebar();
13+ const { join } = useStartConversation();
14+ const colors = useColors();
15+ const mine = new Set((sidebar.data?.entries ?? []).map((entry) => entry.channel.id));
16+
17+ return (
18+ <Screen>
19+ <Stack.Screen options={{ title: "Browse channels" }} />
20+ {channels.isPending ? (
21+ <Loading />
22+ ) : channels.error ? (
23+ <ErrorView error={channels.error} retry={() => channels.refetch()} />
24+ ) : (
25+ <FlatList
26+ data={channels.data}
27+ keyExtractor={(channel) => channel.id}
28+ refreshControl={<RefreshControl refreshing={channels.isRefetching} onRefresh={() => channels.refetch()} tintColor={colors.accent} />}
29+ ListEmptyComponent={<Empty icon={Hash} title="No other channels" />}
30+ renderItem={({ item }) => {
31+ const joined = mine.has(item.id);
32+ const open = () => router.replace({ pathname: "/chat/[channel]", params: { channel: item.id } });
33+ return (
34+ <Row
35+ leading={<Hash size={20} color={colors.muted} />}
36+ title={item.name ?? ""}
37+ subtitle={item.topic ?? undefined}
38+ chevron={false}
39+ onPress={joined ? open : undefined}
40+ trailing={
41+ joined ? (
42+ <Badge label="Joined" />
43+ ) : (
44+ <Button
45+ title="Join"
46+ variant="secondary"
47+ busy={join.isPending && join.variables === item.id}
48+ onPress={() => join.mutate(item.id, { onSuccess: open })}
49+ />
50+ )
51+ }
52+ />
53+ );
54+ }}
55+ />
56+ )}
57+ </Screen>
58+ );
59+}
+170−0
1+import type { ChatSidebarEntry } from "@g1t/contracts";
2+import { Stack, router } from "expo-router";
3+import { Hash, Lock, MessagesSquare, Search, SquarePen } from "lucide-react-native";
4+import { useMemo, useState } from "react";
5+import { Pressable, RefreshControl, SectionList, StyleSheet, TextInput, View } from "react-native";
6+
7+import { Avatar, Badge, Button, Empty, ErrorView, Loading, Screen, SectionHeader, Text } from "@/components/ui";
8+import { useChatSidebar } from "@/lib/chat";
9+import { useFeed } from "@/lib/feed";
10+import { useSignedIn } from "@/lib/session";
11+import { ago } from "@/lib/time";
12+import { radius, space, useColors } from "@/theme";
13+
14+/**
15+ * Chat: starred conversations, the channels you are in, and your direct
16+ * messages, with what is unread. Counts move live with the feed
17+ * (lib/feed.tsx); pull down to read everything again.
18+ */
19+export default function ChatList() {
20+ const { workspace } = useSignedIn();
21+ const sidebar = useChatSidebar();
22+ const colors = useColors();
23+ const [search, setSearch] = useState("");
24+
25+ const sections = useMemo(() => {
26+ const words = search.trim().toLowerCase();
27+ const entries = (sidebar.data?.entries ?? []).filter(
28+ (entry) => !entry.channel.archived_at && (!words || `${entry.title} ${entry.channel.name ?? ""}`.toLowerCase().includes(words)),
29+ );
30+ const byRecent = (a: ChatSidebarEntry, b: ChatSidebarEntry) => (b.channel.last_message_at ?? "").localeCompare(a.channel.last_message_at ?? "");
31+ return [
32+ { title: "Starred", data: entries.filter((entry) => entry.starred).sort(byRecent) },
33+ { title: "Channels", data: entries.filter((entry) => !entry.starred && entry.channel.kind === "channel").sort((a, b) => (a.channel.name ?? "").localeCompare(b.channel.name ?? "")) },
34+ { title: "Direct messages", data: entries.filter((entry) => !entry.starred && entry.channel.kind === "dm").sort(byRecent) },
35+ ].filter((section) => section.data.length > 0);
36+ }, [sidebar.data, search]);
37+
38+ const header = (
39+ <Stack.Screen
40+ options={{
41+ title: workspace?.name ?? workspace?.slug ?? "Chat",
42+ headerRight: () => (
43+ <Pressable accessibilityLabel="New message" hitSlop={10} onPress={() => router.push("/chat/new")}>
44+ <SquarePen size={22} color={colors.fg} />
45+ </Pressable>
46+ ),
47+ }}
48+ />
49+ );
50+
51+ if (!workspace) {
52+ return (
53+ <Screen>
54+ {header}
55+ <Empty icon={MessagesSquare} title="No workspace yet" body="Create or join a workspace on g1t, then come back." />
56+ </Screen>
57+ );
58+ }
59+
60+ return (
61+ <Screen>
62+ {header}
63+ {sidebar.isPending ? (
64+ <Loading />
65+ ) : sidebar.error ? (
66+ <ErrorView error={sidebar.error} retry={() => sidebar.refetch()} />
67+ ) : (
68+ <SectionList
69+ sections={sections}
70+ keyExtractor={(entry) => entry.channel.id}
71+ stickySectionHeadersEnabled={false}
72+ keyboardShouldPersistTaps="handled"
73+ ListHeaderComponent={
74+ <View style={[styles.search, { backgroundColor: colors.raised }]}>
75+ <Search size={16} color={colors.faint} />
76+ <TextInput
77+ value={search}
78+ onChangeText={setSearch}
79+ placeholder="Find a conversation"
80+ placeholderTextColor={colors.faint}
81+ style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: 8 }}
82+ autoCorrect={false}
83+ autoCapitalize="none"
84+ />
85+ </View>
86+ }
87+ renderSectionHeader={({ section }) => <SectionHeader title={section.title} />}
88+ renderItem={({ item }) => <Conversation entry={item} />}
89+ refreshControl={<RefreshControl refreshing={sidebar.isRefetching} onRefresh={() => sidebar.refetch()} tintColor={colors.accent} />}
90+ ListEmptyComponent={
91+ search ? (
92+ <Empty title="Nothing matches" />
93+ ) : (
94+ <Empty icon={MessagesSquare} title="No conversations yet" body="Message a person or an agent, or join a channel." action={<Button title="New message" onPress={() => router.push("/chat/new")} />} />
95+ )
96+ }
97+ ListFooterComponent={
98+ sidebar.data && sidebar.data.browsable > 0 && !search ? (
99+ <Pressable onPress={() => router.push("/chat/browse")} style={styles.browse}>
100+ <Hash size={16} color={colors.accent} />
101+ <Text tone="accent" style={{ fontWeight: "600" }}>
102+ Browse {sidebar.data.browsable} more {sidebar.data.browsable === 1 ? "channel" : "channels"}
103+ </Text>
104+ </Pressable>
105+ ) : (
106+ <View style={{ height: space.xl }} />
107+ )
108+ }
109+ />
110+ )}
111+ </Screen>
112+ );
113+}
114+
115+function Conversation({ entry }: { entry: ChatSidebarEntry }) {
116+ const colors = useColors();
117+ const { server } = useSignedIn();
118+ const { presence } = useFeed();
119+ const { channel } = entry;
120+ const dm = channel.kind === "dm";
121+ const other = entry.others[0];
122+ const loud = entry.unread > 0 && !entry.muted;
123+ const online = dm && other && other.kind === "user" && presence.get(other.id) === "active";
124+ const title = dm ? entry.title : (channel.name ?? entry.title);
125+
126+ return (
127+ <Pressable
128+ onPress={() => router.push({ pathname: "/chat/[channel]", params: { channel: channel.id } })}
129+ style={({ pressed }) => [styles.row, { backgroundColor: pressed ? colors.raised : "transparent" }]}
130+ >
131+ <View>
132+ {dm && other ? (
133+ <Avatar name={other.display_name} uri={other.avatar ? `${server.site}/avatars/${other.avatar}` : null} agent={other.kind === "agent"} size={40} />
134+ ) : (
135+ <View style={[styles.icon, { backgroundColor: colors.raised }]}>
136+ {channel.private ? <Lock size={18} color={colors.muted} /> : <Hash size={18} color={colors.muted} />}
137+ </View>
138+ )}
139+ {online ? <View style={[styles.online, { backgroundColor: colors.success, borderColor: colors.bg }]} /> : null}
140+ </View>
141+ <View style={{ flex: 1, gap: 2 }}>
142+ <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}>
143+ <Text numberOfLines={1} style={{ flex: 1, fontWeight: loud ? "800" : "500", color: entry.muted ? colors.muted : colors.fg }}>
144+ {title}
145+ </Text>
146+ {channel.last_message_at ? (
147+ <Text size="small" tone="faint">
148+ {ago(channel.last_message_at)}
149+ </Text>
150+ ) : null}
151+ </View>
152+ <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}>
153+ <Text size="small" tone="muted" numberOfLines={1} style={{ flex: 1 }}>
154+ {dm ? (other?.kind === "agent" ? (other.title ?? other.role ?? "Agent") : `@${other?.name ?? ""}`) : (channel.topic ?? (entry.muted ? "Muted" : " "))}
155+ </Text>
156+ {entry.mentions > 0 ? <Badge label={`@ ${entry.mentions}`} tone="accent" /> : loud ? <View style={[styles.dot, { backgroundColor: colors.accent }]} /> : null}
157+ </View>
158+ </View>
159+ </Pressable>
160+ );
161+}
162+
163+const styles = StyleSheet.create({
164+ search: { flexDirection: "row", alignItems: "center", gap: space.sm, marginHorizontal: space.lg, marginTop: space.md, paddingHorizontal: space.md, borderRadius: radius.md },
165+ row: { flexDirection: "row", alignItems: "center", gap: space.md, paddingHorizontal: space.lg, paddingVertical: 10 },
166+ icon: { width: 40, height: 40, borderRadius: radius.md, alignItems: "center", justifyContent: "center" },
167+ online: { position: "absolute", right: -1, bottom: -1, width: 12, height: 12, borderRadius: 6, borderWidth: 2 },
168+ dot: { width: 9, height: 9, borderRadius: 5 },
169+ browse: { flexDirection: "row", alignItems: "center", gap: space.sm, padding: space.lg },
170+});
+170−0
1+import type { MemberProfile } from "@g1t/contracts";
2+import { useQuery } from "@tanstack/react-query";
3+import { Stack, router } from "expo-router";
4+import { Check, Hash, Search } from "lucide-react-native";
5+import { useMemo, useState } from "react";
6+import { FlatList, Pressable, StyleSheet, TextInput, View } from "react-native";
7+
8+import { PromptSheet } from "@/components/chat/sheet";
9+import { Avatar, Button, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui";
10+import { useChatSidebar, useStartConversation } from "@/lib/chat";
11+import { useSignedIn } from "@/lib/session";
12+import { radius, space, useColors } from "@/theme";
13+
14+type Person = { key: string; name: string; handle: string; avatar: string | null; agent: boolean; detail: string | null };
15+
16+/**
17+ * A new conversation: pick people and agents for a direct message (one,
18+ * or a group), or make a channel. People come from the workspace's
19+ * members (`GET /workspaces/<ws>/members`); agents from the conversations
20+ * you already have with them.
21+ */
22+export default function NewConversation() {
23+ const { client, workspace, me, server } = useSignedIn();
24+ const slug = workspace?.slug ?? "";
25+ const colors = useColors();
26+ const sidebar = useChatSidebar();
27+ const start = useStartConversation();
28+ const [search, setSearch] = useState("");
29+ const [picked, setPicked] = useState<Person[]>([]);
30+ const [making, setMaking] = useState(false);
31+ const [error, setError] = useState<string | null>(null);
32+
33+ const members = useQuery({
34+ queryKey: ["workspace", slug, "members"],
35+ queryFn: () =>
36+ client.api<{ username: string; display_username?: string; name?: string | null; avatar?: string | null }[]>(`/workspaces/${encodeURIComponent(slug)}/members`),
37+ enabled: !!slug,
38+ });
39+
40+ const everyone = useMemo(() => {
41+ const agents = new Map<string, MemberProfile>();
42+ for (const entry of sidebar.data?.entries ?? []) for (const other of entry.others) if (other.kind === "agent") agents.set(other.id, other);
43+ const people: Person[] = (members.data ?? [])
44+ .filter((member) => member.username.toLowerCase() !== me.username.toLowerCase())
45+ .map((member) => ({
46+ key: `user:${member.username.toLowerCase()}`,
47+ name: member.name || member.display_username || member.username,
48+ handle: member.display_username ?? member.username,
49+ avatar: member.avatar ?? null,
50+ agent: false,
51+ detail: null,
52+ }));
53+ const bots: Person[] = [...agents.values()].map((agent) => ({
54+ key: `agent:${agent.id}`,
55+ name: agent.display_name,
56+ handle: agent.name,
57+ avatar: agent.avatar,
58+ agent: true,
59+ detail: agent.title ?? agent.role,
60+ }));
61+ const words = search.trim().toLowerCase();
62+ return [...bots, ...people].filter((one) => !words || `${one.name} ${one.handle}`.toLowerCase().includes(words));
63+ }, [members.data, sidebar.data, me.username, search]);
64+
65+ const toggle = (person: Person) =>
66+ setPicked((now) => (now.some((one) => one.key === person.key) ? now.filter((one) => one.key !== person.key) : [...now, person]));
67+
68+ const open = () => {
69+ setError(null);
70+ start.dm.mutate(
71+ picked.map((one) => one.key),
72+ {
73+ onSuccess: ({ channel }) => router.replace({ pathname: "/chat/[channel]", params: { channel: channel.id } }),
74+ onError: (failure) => setError(failure.message),
75+ },
76+ );
77+ };
78+
79+ return (
80+ <Screen>
81+ <Stack.Screen options={{ title: "New message", presentation: "modal" }} />
82+ <View style={[styles.search, { backgroundColor: colors.raised }]}>
83+ <Search size={16} color={colors.faint} />
84+ <TextInput
85+ autoFocus
86+ value={search}
87+ onChangeText={setSearch}
88+ placeholder="Find a person or an agent"
89+ placeholderTextColor={colors.faint}
90+ style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: 8 }}
91+ autoCorrect={false}
92+ autoCapitalize="none"
93+ />
94+ </View>
95+ {picked.length ? (
96+ <Text size="small" tone="muted" style={{ paddingHorizontal: space.lg, paddingTop: space.sm }}>
97+ To {picked.map((one) => one.name).join(", ")}
98+ </Text>
99+ ) : null}
100+ {members.isPending ? (
101+ <Loading />
102+ ) : (
103+ <FlatList
104+ data={everyone}
105+ keyExtractor={(one) => one.key}
106+ keyboardShouldPersistTaps="handled"
107+ ListHeaderComponent={
108+ <>
109+ <Row leading={<Hash size={20} color={colors.accent} />} title="Make a channel" subtitle="A conversation for a team or a topic" onPress={() => setMaking(true)} />
110+ <SectionHeader title="People and agents" />
111+ </>
112+ }
113+ renderItem={({ item }) => {
114+ const on = picked.some((one) => one.key === item.key);
115+ return (
116+ <Pressable onPress={() => toggle(item)} style={({ pressed }) => [styles.person, { backgroundColor: pressed ? colors.raised : "transparent" }]}>
117+ <Avatar name={item.name} uri={item.avatar ? `${server.site}/avatars/${item.avatar}` : null} agent={item.agent} size={36} />
118+ <View style={{ flex: 1 }}>
119+ <Text style={{ fontWeight: "600" }} numberOfLines={1}>
120+ {item.name}
121+ </Text>
122+ <Text size="small" tone="muted" numberOfLines={1}>
123+ {item.agent ? (item.detail ?? "Agent") : `@${item.handle}`}
124+ </Text>
125+ </View>
126+ <View style={[styles.check, { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accent : "transparent" }]}>
127+ {on ? <Check size={14} color={colors.onAccent} strokeWidth={3} /> : null}
128+ </View>
129+ </Pressable>
130+ );
131+ }}
132+ />
133+ )}
134+ {error ? (
135+ <Text tone="danger" style={{ paddingHorizontal: space.lg }}>
136+ {error}
137+ </Text>
138+ ) : null}
139+ {picked.length ? (
140+ <View style={{ padding: space.lg }}>
141+ <Button title={picked.length === 1 ? `Message ${picked[0].name}` : `Message ${picked.length}`} busy={start.dm.isPending} onPress={open} />
142+ </View>
143+ ) : null}
144+ <PromptSheet
145+ visible={making}
146+ title="Make a channel"
147+ label="Lowercase words joined by dashes, like design-reviews."
148+ placeholder="channel-name"
149+ confirm="Make it"
150+ onCancel={() => setMaking(false)}
151+ onDone={(name) => {
152+ setMaking(false);
153+ start.create.mutate(
154+ { name },
155+ {
156+ onSuccess: ({ channel }) => router.replace({ pathname: "/chat/[channel]", params: { channel: channel.id } }),
157+ onError: (failure) => setError(failure.message),
158+ },
159+ );
160+ }}
161+ />
162+ </Screen>
163+ );
164+}
165+
166+const styles = StyleSheet.create({
167+ search: { flexDirection: "row", alignItems: "center", gap: space.sm, marginHorizontal: space.lg, marginTop: space.md, paddingHorizontal: space.md, borderRadius: radius.md },
168+ person: { flexDirection: "row", alignItems: "center", gap: space.md, paddingHorizontal: space.lg, paddingVertical: 10 },
169+ check: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, alignItems: "center", justifyContent: "center" },
170+});
+19−0
1+import { Stack, useLocalSearchParams } from "expo-router";
2+
3+import { Conversation } from "@/components/chat/conversation";
4+import { Screen } from "@/components/ui";
5+import { useChatSidebar } from "@/lib/chat";
6+
7+/** A thread: the message it is under, its replies, and a box to reply in. */
8+export default function ThreadScreen() {
9+ const { channel, root } = useLocalSearchParams<{ channel: string; root: string }>();
10+ const sidebar = useChatSidebar();
11+ const entry = sidebar.data?.entries.find((one) => one.channel.id === channel);
12+ const where = entry ? (entry.channel.kind === "dm" ? entry.title : `#${entry.channel.name}`) : "";
13+ return (
14+ <Screen>
15+ <Stack.Screen options={{ title: where ? `Thread in ${where}` : "Thread" }} />
16+ <Conversation channelId={channel} thread={root} title={where} />
17+ </Screen>
18+ );
19+}
+100−0
1+import { Stack, router, useLocalSearchParams } from "expo-router";
2+import { CircleDot, GitPullRequest } from "lucide-react-native";
3+import { useState } from "react";
4+import { FlatList, RefreshControl, View } from "react-native";
5+
6+import { Checks, IssueIcon, PullIcon } from "@/components/code";
7+import { Segments } from "@/components/segments";
8+import { Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui";
9+import { type ApiIssue, type ApiPull, useIssues, usePulls, useRepo } from "@/lib/code";
10+import { ago } from "@/lib/time";
11+import { space, useColors } from "@/theme";
12+
13+type Tab = "pulls" | "issues";
14+type State = "open" | "closed";
15+
16+/** A repository: its pull requests and issues. */
17+export default function Repository() {
18+ const { owner, repo } = useLocalSearchParams<{ owner: string; repo: string }>();
19+ const [tab, setTab] = useState<Tab>("pulls");
20+ const [state, setState] = useState<State>("open");
21+ const details = useRepo(owner, repo);
22+ const pulls = usePulls(owner, repo, state);
23+ const issues = useIssues(owner, repo, state);
24+ const list = tab === "pulls" ? pulls : issues;
25+ const colors = useColors();
26+
27+ return (
28+ <Screen>
29+ <Stack.Screen options={{ title: repo }} />
30+ <View style={{ padding: space.lg, gap: space.md }}>
31+ <Text size="small" tone="muted">
32+ {owner}/{repo}
33+ {details.data?.description ? ` · ${details.data.description}` : ""}
34+ </Text>
35+ <Segments
36+ value={tab}
37+ onChange={setTab}
38+ options={[
39+ { value: "pulls", label: "Pull requests" },
40+ { value: "issues", label: "Issues" },
41+ ]}
42+ />
43+ <Segments
44+ value={state}
45+ onChange={setState}
46+ options={[
47+ { value: "open", label: "Open" },
48+ { value: "closed", label: "Closed" },
49+ ]}
50+ />
51+ </View>
52+ {list.isPending ? (
53+ <Loading />
54+ ) : list.error ? (
55+ <ErrorView error={list.error} retry={() => list.refetch()} />
56+ ) : tab === "pulls" ? (
57+ <FlatList
58+ data={pulls.data}
59+ keyExtractor={(pull) => pull.id}
60+ refreshControl={<RefreshControl refreshing={pulls.isRefetching} onRefresh={() => pulls.refetch()} tintColor={colors.accent} />}
61+ ListEmptyComponent={<Empty icon={GitPullRequest} title={`No ${state} pull requests`} />}
62+ renderItem={({ item }) => <PullRow pull={item} owner={owner} repo={repo} />}
63+ />
64+ ) : (
65+ <FlatList
66+ data={issues.data}
67+ keyExtractor={(issue) => issue.id}
68+ refreshControl={<RefreshControl refreshing={issues.isRefetching} onRefresh={() => issues.refetch()} tintColor={colors.accent} />}
69+ ListEmptyComponent={<Empty icon={CircleDot} title={`No ${state} issues`} />}
70+ renderItem={({ item }) => <IssueRow issue={item} owner={owner} repo={repo} />}
71+ />
72+ )}
73+ </Screen>
74+ );
75+}
76+
77+function PullRow({ pull, owner, repo }: { pull: ApiPull; owner: string; repo: string }) {
78+ const by = pull.author.display_username ?? pull.author.username;
79+ return (
80+ <Row
81+ leading={<PullIcon pull={pull} />}
82+ title={pull.title}
83+ subtitle={`#${pull.number} · ${by}${pull.agent ? ` with ${pull.agent}` : ""} · ${ago(pull.updated_at)}`}
84+ trailing={<Checks status={pull.check_status} />}
85+ onPress={() => router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo, number: String(pull.number) } })}
86+ />
87+ );
88+}
89+
90+function IssueRow({ issue, owner, repo }: { issue: ApiIssue; owner: string; repo: string }) {
91+ const by = issue.author.display_username ?? issue.author.username;
92+ return (
93+ <Row
94+ leading={<IssueIcon issue={issue} />}
95+ title={issue.title}
96+ subtitle={`#${issue.number} · ${by} · ${ago(issue.updated_at)}${issue.comment_count ? ` · ${issue.comment_count} comments` : ""}`}
97+ onPress={() => router.push({ pathname: "/code/[owner]/[repo]/issues/[number]", params: { owner, repo, number: String(issue.number) } })}
98+ />
99+ );
100+}
+86−0
1+import { Stack, router, useLocalSearchParams } from "expo-router";
2+import * as WebBrowser from "expo-web-browser";
3+import { ExternalLink, Send } from "lucide-react-native";
4+import { useState } from "react";
5+import { View } from "react-native";
6+
7+import { Body, IssueIcon, PullIcon, Timeline } from "@/components/code";
8+import { Button, ErrorView, Field, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui";
9+import { useComment, useIssue } from "@/lib/code";
10+import { siteUrl } from "@/lib/server";
11+import { useSignedIn } from "@/lib/session";
12+import { ago } from "@/lib/time";
13+import { space } from "@/theme";
14+
15+/** An issue: what it asks for, the pull requests made for it, and its conversation. */
16+export default function Issue() {
17+ const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>();
18+ const number = Number(params.number);
19+ const { server } = useSignedIn();
20+ const issue = useIssue(params.owner, params.repo, number);
21+ const comment = useComment(params.owner, params.repo, number);
22+ const [draft, setDraft] = useState("");
23+
24+ if (issue.isPending || issue.error) {
25+ return (
26+ <Screen>
27+ <Stack.Screen options={{ title: `#${number}` }} />
28+ {issue.error ? <ErrorView error={issue.error} retry={() => issue.refetch()} /> : <Loading />}
29+ </Screen>
30+ );
31+ }
32+
33+ const { issue: item, pulls, comments } = issue.data;
34+ const by = item.author.display_username ?? item.author.username;
35+
36+ return (
37+ <Screen scroll>
38+ <Stack.Screen options={{ title: `#${number}` }} />
39+ <View style={{ gap: space.sm }}>
40+ <Text size="heading">{item.title}</Text>
41+ <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}>
42+ <IssueIcon issue={item} />
43+ <Text size="small" tone="muted">
44+ {item.state === "open" ? "Open" : "Closed"} · {by} · {ago(item.created_at)}
45+ {item.labels.length ? ` · ${item.labels.join(", ")}` : ""}
46+ </Text>
47+ </View>
48+ </View>
49+ <Body text={item.body} />
50+ {pulls.length ? (
51+ <View style={{ marginHorizontal: -space.lg }}>
52+ <SectionHeader title="Pull requests" />
53+ {pulls.map((pull) => (
54+ <Row
55+ key={pull.id}
56+ leading={<PullIcon pull={pull} />}
57+ title={pull.title}
58+ subtitle={`#${pull.number} · ${pull.status}`}
59+ onPress={() =>
60+ router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner: params.owner, repo: params.repo, number: String(pull.number) } })
61+ }
62+ />
63+ ))}
64+ </View>
65+ ) : null}
66+ <Timeline comments={comments} />
67+ <View style={{ gap: space.sm }}>
68+ <Field label="Comment" placeholder="Add to the conversation" value={draft} onChangeText={setDraft} multiline />
69+ <Button
70+ title="Comment"
71+ icon={Send}
72+ busy={comment.isPending}
73+ disabled={!draft.trim()}
74+ onPress={() => comment.mutate(draft.trim(), { onSuccess: () => setDraft("") })}
75+ />
76+ {comment.error ? <Text tone="danger">{comment.error.message}</Text> : null}
77+ </View>
78+ <Button
79+ title="Open on g1t"
80+ variant="secondary"
81+ icon={ExternalLink}
82+ onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/issues/${number}`))}
83+ />
84+ </Screen>
85+ );
86+}
+121−0
1+import { Stack, useLocalSearchParams } from "expo-router";
2+import * as WebBrowser from "expo-web-browser";
3+import { Check, ExternalLink, MessageSquareWarning } from "lucide-react-native";
4+import { useState } from "react";
5+import { View } from "react-native";
6+
7+import { Body, Checks, Diff, StateBadge, Timeline } from "@/components/code";
8+import { Segments } from "@/components/segments";
9+import { Button, ErrorView, Field, Loading, Screen, Text } from "@/components/ui";
10+import { usePull, usePullChanges, useReview } from "@/lib/code";
11+import { siteUrl } from "@/lib/server";
12+import { useSignedIn } from "@/lib/session";
13+import { ago } from "@/lib/time";
14+import { space } from "@/theme";
15+
16+/**
17+ * A pull request: what it is for, its checks, the conversation, and what
18+ * it changes; approve it or ask for changes from here. Merging stays on
19+ * the site for now, where its rules and the merge queue are shown.
20+ */
21+export default function PullRequest() {
22+ const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>();
23+ const number = Number(params.number);
24+ const { server } = useSignedIn();
25+ const [tab, setTab] = useState<"conversation" | "files">("conversation");
26+ const pull = usePull(params.owner, params.repo, number);
27+ const changes = usePullChanges(params.owner, params.repo, number, tab === "files");
28+ const review = useReview(params.owner, params.repo, number);
29+ const [note, setNote] = useState("");
30+
31+ const open = () => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/pull/${number}`));
32+
33+ if (pull.isPending) {
34+ return (
35+ <Screen>
36+ <Stack.Screen options={{ title: `#${number}` }} />
37+ <Loading />
38+ </Screen>
39+ );
40+ }
41+ if (pull.error) {
42+ return (
43+ <Screen>
44+ <Stack.Screen options={{ title: `#${number}` }} />
45+ <ErrorView error={pull.error} retry={() => pull.refetch()} />
46+ </Screen>
47+ );
48+ }
49+
50+ const { pull: pr, comments } = pull.data;
51+ const by = pr.author.display_username ?? pr.author.username;
52+ const reviewable = pr.status === "open";
53+
54+ return (
55+ <Screen scroll>
56+ <Stack.Screen options={{ title: `#${number}` }} />
57+ <View style={{ gap: space.sm }}>
58+ <Text size="heading">{pr.title}</Text>
59+ <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center", flexWrap: "wrap" }}>
60+ <StateBadge pull={pr} />
61+ <Checks status={pr.check_status} />
62+ <Text size="small" tone="muted">
63+ {by}
64+ {pr.agent ? ` with ${pr.agent}` : ""} · {pr.branch ?? "a fork"} → {pr.base} · {ago(pr.updated_at)}
65+ </Text>
66+ </View>
67+ </View>
68+
69+ <Segments
70+ value={tab}
71+ onChange={setTab}
72+ options={[
73+ { value: "conversation", label: "Conversation" },
74+ { value: "files", label: `Files · ${pr.files.length}` },
75+ ]}
76+ />
77+
78+ {tab === "conversation" ? (
79+ <>
80+ <Body text={pr.body} />
81+ <Timeline comments={comments} />
82+ {reviewable ? (
83+ <View style={{ gap: space.md }}>
84+ <Field label="Review" placeholder="Say what you think (optional to approve)" value={note} onChangeText={setNote} multiline />
85+ <View style={{ flexDirection: "row", gap: space.sm }}>
86+ <View style={{ flex: 1 }}>
87+ <Button
88+ title="Approve"
89+ icon={Check}
90+ busy={review.isPending && review.variables?.verdict === "approve"}
91+ disabled={review.isPending}
92+ onPress={() => review.mutate({ verdict: "approve", body: note.trim() }, { onSuccess: () => setNote("") })}
93+ />
94+ </View>
95+ <View style={{ flex: 1 }}>
96+ <Button
97+ title="Request changes"
98+ variant="secondary"
99+ icon={MessageSquareWarning}
100+ busy={review.isPending && review.variables?.verdict === "request_changes"}
101+ disabled={review.isPending || !note.trim()}
102+ onPress={() => review.mutate({ verdict: "request_changes", body: note.trim() }, { onSuccess: () => setNote("") })}
103+ />
104+ </View>
105+ </View>
106+ {review.error ? <Text tone="danger">{review.error.message}</Text> : null}
107+ </View>
108+ ) : null}
109+ </>
110+ ) : changes.isPending ? (
111+ <Loading />
112+ ) : changes.error ? (
113+ <ErrorView error={changes.error} retry={() => changes.refetch()} />
114+ ) : (
115+ <Diff changes={changes.data} />
116+ )}
117+
118+ <Button title="Open on g1t" variant="secondary" icon={ExternalLink} onPress={open} />
119+ </Screen>
120+ );
121+}
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+58−0
1+import { hasCodeAccess } from "@g1t/contracts";
2+import { Stack, router } from "expo-router";
3+import { BookLock, BookMarked, Code2 } from "lucide-react-native";
4+import { useState } from "react";
5+import { FlatList, RefreshControl, View } from "react-native";
6+
7+import { Badge, Empty, ErrorView, Field, Loading, Row, Screen } from "@/components/ui";
8+import { useRepos } from "@/lib/code";
9+import { useSignedIn } from "@/lib/session";
10+import { ago } from "@/lib/time";
11+import { space, useColors } from "@/theme";
12+
13+/** Code: the workspace's repositories you can see, newest first. */
14+export default function Repos() {
15+ const { workspace } = useSignedIn();
16+ const [search, setSearch] = useState("");
17+ const repos = useRepos(search.trim());
18+ const colors = useColors();
19+
20+ if (workspace && !hasCodeAccess(workspace)) {
21+ return (
22+ <Screen>
23+ <Stack.Screen options={{ title: "Code" }} />
24+ <Empty icon={Code2} title="Code is not on for you here" body={`${workspace.name ?? workspace.slug} gives you Chat, Docs and agents. Ask an owner for Code.`} />
25+ </Screen>
26+ );
27+ }
28+
29+ return (
30+ <Screen>
31+ <Stack.Screen options={{ title: "Code" }} />
32+ <View style={{ padding: space.lg, paddingBottom: space.sm }}>
33+ <Field label="Find a repository" placeholder="Name or description" value={search} onChangeText={setSearch} returnKeyType="search" />
34+ </View>
35+ {repos.isPending ? (
36+ <Loading />
37+ ) : repos.error ? (
38+ <ErrorView error={repos.error} retry={() => repos.refetch()} />
39+ ) : (
40+ <FlatList
41+ data={repos.data}
42+ keyExtractor={(repo) => repo.id}
43+ refreshControl={<RefreshControl refreshing={repos.isRefetching} onRefresh={() => repos.refetch()} tintColor={colors.accent} />}
44+ ListEmptyComponent={<Empty icon={BookMarked} title={search ? "Nothing matches" : "No repositories yet"} />}
45+ renderItem={({ item }) => (
46+ <Row
47+ leading={item.is_private ? <BookLock size={20} color={colors.muted} /> : <BookMarked size={20} color={colors.muted} />}
48+ title={`${item.namespace}/${item.name}`}
49+ subtitle={item.description ?? `Created ${ago(item.created_at)}`}
50+ trailing={item.archived_at ? <Badge label="Archived" tone="warn" /> : null}
51+ onPress={() => router.push({ pathname: "/code/[owner]/[repo]", params: { owner: item.namespace, repo: item.name } })}
52+ />
53+ )}
54+ />
55+ )}
56+ </Screen>
57+ );
58+}
+47−0
1+import { CUSTOMIZE_SECTIONS, type CustomizeSection } from "@g1t/contracts/customize";
2+import { Redirect, Stack, useLocalSearchParams } from "expo-router";
3+import * as WebBrowser from "expo-web-browser";
4+import { ExternalLink } from "lucide-react-native";
5+import { SectionList, View } from "react-native";
6+
7+import { Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui";
8+import { CUSTOMIZE, type CustomizeItem, customizeItems, customizePath } from "@/lib/customize";
9+import { siteUrl } from "@/lib/server";
10+import { useSignedIn } from "@/lib/session";
11+import { space } from "@/theme";
12+
13+/** One part of Customize: its listings, grouped, each opening where it is set up. */
14+export default function CustomizeSectionScreen() {
15+ const { section } = useLocalSearchParams<{ section: string }>();
16+ const { workspace, server } = useSignedIn();
17+ if (!CUSTOMIZE_SECTIONS.includes(section as CustomizeSection)) return <Redirect href="/customize" />;
18+ const id = section as CustomizeSection;
19+ const slug = workspace?.slug ?? "";
20+ const items = customizeItems(id, slug);
21+ const groups = [...new Set(items.map((item) => item.group))].map((group) => ({ title: group, data: items.filter((item) => item.group === group) }));
22+ const open = (path: string) => WebBrowser.openBrowserAsync(siteUrl(server, path));
23+
24+ return (
25+ <Screen>
26+ <Stack.Screen options={{ title: CUSTOMIZE[id].title }} />
27+ <SectionList
28+ sections={groups}
29+ keyExtractor={(item) => item.id}
30+ stickySectionHeadersEnabled={false}
31+ ListHeaderComponent={
32+ <View style={{ padding: space.lg, gap: space.md }}>
33+ <Text tone="muted">{CUSTOMIZE[id].description}</Text>
34+ <Button title={`Manage ${CUSTOMIZE[id].title.toLowerCase()} on g1t`} variant="secondary" icon={ExternalLink} onPress={() => open(customizePath(id, slug))} />
35+ </View>
36+ }
37+ renderSectionHeader={({ section: group }) => <SectionHeader title={group.title} />}
38+ renderItem={({ item }) => <Item item={item} onPress={() => open(item.sitePath)} />}
39+ />
40+ </Screen>
41+ );
42+}
43+
44+function Item({ item, onPress }: { item: CustomizeItem; onPress: () => void }) {
45+ const badge = item.status === "available" ? <Badge label="Available" tone="success" /> : item.status === "soon" ? <Badge label="Soon" /> : <Badge label="Built in" tone="accent" />;
46+ return <Row title={item.name} subtitle={item.description} trailing={badge} onPress={item.status === "soon" ? undefined : onPress} />;
47+}
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+43−0
1+import { CUSTOMIZE_SECTIONS } from "@g1t/contracts/customize";
2+import { Stack, router } from "expo-router";
3+import { Blocks, Plug, Sparkles } from "lucide-react-native";
4+import { View } from "react-native";
5+
6+import { Row, Screen, Text } from "@/components/ui";
7+import { CUSTOMIZE, customizeItems } from "@/lib/customize";
8+import { useSignedIn } from "@/lib/session";
9+import { space, useColors } from "@/theme";
10+
11+const ICONS = { skills: Sparkles, connectors: Plug, plugins: Blocks } as const;
12+
13+/**
14+ * Customize: what shapes how this workspace's agents work. Skills are what
15+ * they know how to do, Connectors what they can reach, Plugins what adds
16+ * to the workspace. It replaces the Marketplace.
17+ */
18+export default function Customize() {
19+ const { workspace } = useSignedIn();
20+ const colors = useColors();
21+ const slug = workspace?.slug ?? "";
22+ return (
23+ <Screen>
24+ <Stack.Screen options={{ title: "Customize" }} />
25+ <View style={{ padding: space.lg }}>
26+ <Text tone="muted">Skills, connectors and plugins for {workspace?.name ?? slug}. Owners add them; anyone else asks an owner.</Text>
27+ </View>
28+ {CUSTOMIZE_SECTIONS.map((section) => {
29+ const Icon = ICONS[section];
30+ const count = customizeItems(section, slug).length;
31+ return (
32+ <Row
33+ key={section}
34+ leading={<Icon size={22} color={colors.accent} />}
35+ title={`${CUSTOMIZE[section].title} · ${count}`}
36+ subtitle={CUSTOMIZE[section].description}
37+ onPress={() => router.push({ pathname: "/customize/[section]", params: { section } })}
38+ />
39+ );
40+ })}
41+ </Screen>
42+ );
43+}
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+15−0
1+import { House } from "lucide-react-native";
2+
3+import { ComingSoon } from "@/components/coming-soon";
4+
5+/** Home: where you're needed now, and what happened while you were away. */
6+export default function Home() {
7+ return (
8+ <ComingSoon
9+ title="Home"
10+ icon={House}
11+ body="Where you're needed now and what happened while you were away. Chat already has what is waiting on you."
12+ path={(workspace) => `/${workspace}/-/home`}
13+ />
14+ );
15+}
+6−0
1+import { Redirect } from "expo-router";
2+
3+/** The app opens on Chat. */
4+export default function Index() {
5+ return <Redirect href="/chat" />;
6+}
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+115−0
1+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2+import Constants from "expo-constants";
3+import { Stack, router } from "expo-router";
4+import * as WebBrowser from "expo-web-browser";
5+import { Bell, BellRing, Blocks, Check, ExternalLink, LogOut } from "lucide-react-native";
6+import { Linking, View } from "react-native";
7+
8+import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui";
9+import { pushState, registerForPush, sendTestPush } from "@/lib/push";
10+import { isHosted, siteUrl } from "@/lib/server";
11+import { useSignedIn } from "@/lib/session";
12+import { space, useColors } from "@/theme";
13+
14+/** More: Customize, your workspaces, notifications on this phone, how you signed in, and signing out. */
15+export default function More() {
16+ const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn();
17+ const colors = useColors();
18+ const queries = useQueryClient();
19+ const name = me.display_username ?? me.username;
20+ const oauth = client.credentials.kind === "oauth";
21+ const push = useQuery({ queryKey: ["push-state"], queryFn: pushState });
22+ const test = useMutation({ mutationFn: () => sendTestPush(client) });
23+
24+ const pushRow = {
25+ unsupported: { label: "Not here", detail: "Notifications need a build of the app on a phone: not Expo Go on Android, nor a simulator." },
26+ denied: { label: "Off", detail: "Turned off for g1t in your phone's settings. Tap to open them." },
27+ undetermined: { label: "Off", detail: "Tap to get notified of messages to you, mentions and agents waiting on you." },
28+ granted: { label: "On", detail: "Messages to you, mentions, replies and agents waiting on you, when you're not at g1t elsewhere." },
29+ }[push.data ?? "undetermined"];
30+
31+ return (
32+ <Screen scroll style={{ paddingHorizontal: 0 }}>
33+ <Stack.Screen options={{ title: "More" }} />
34+ <View style={{ flexDirection: "row", gap: space.md, alignItems: "center", paddingHorizontal: space.lg }}>
35+ <Avatar name={name} uri={me.avatar ? `${server.site}/avatars/${me.avatar}` : null} size={48} />
36+ <View style={{ flex: 1 }}>
37+ <Text size="heading">{name}</Text>
38+ <Text size="small" tone="muted">
39+ @{me.username} · {isHosted(server) ? "g1t.sh" : new URL(server.site).host}
40+ </Text>
41+ </View>
42+ </View>
43+
44+ <View>
45+ <Row leading={<Blocks size={22} color={colors.accent} />} title="Customize" subtitle="Skills, connectors and plugins for your agents" onPress={() => router.push("/customize")} />
46+ <Row
47+ leading={<Bell size={22} color={colors.accent} />}
48+ title="Notifications on this phone"
49+ subtitle={pushRow.detail}
50+ trailing={<Badge label={pushRow.label} tone={push.data === "granted" ? "success" : "neutral"} />}
51+ chevron={false}
52+ onPress={
53+ push.data === "denied"
54+ ? () => void Linking.openSettings()
55+ : push.data === "undetermined"
56+ ? () => void registerForPush(client, true).finally(() => queries.invalidateQueries({ queryKey: ["push-state"] }))
57+ : undefined
58+ }
59+ />
60+ {push.data === "granted" ? (
61+ <Row
62+ leading={<BellRing size={22} color={colors.accent} />}
63+ title="Send a test notification"
64+ subtitle={
65+ test.isPending
66+ ? "Sending…"
67+ : test.error
68+ ? test.error.message
69+ : test.data !== undefined
70+ ? test.data > 0
71+ ? `Sent to ${test.data} ${test.data === 1 ? "place" : "places"}: this phone, and any browser with notifications on.`
72+ : "Nothing was sent: g1t has no phone or browser for you. Sign out and in again to register this phone."
73+ : "To check this phone gets them. It arrives in a few seconds, even with the app open; if it never does, see the setup guide's troubleshooting."
74+ }
75+ chevron={false}
76+ onPress={test.isPending ? undefined : () => test.mutate()}
77+ />
78+ ) : null}
79+ </View>
80+
81+ <View>
82+ <SectionHeader title="Workspaces" />
83+ {(me.workspaces ?? []).map((one) => (
84+ <Row
85+ key={one.slug}
86+ leading={<Avatar name={one.name ?? one.slug} uri={one.avatar ? `${server.site}/avatars/${one.avatar}` : null} />}
87+ title={one.name ?? one.slug}
88+ subtitle={`${one.slug} · ${one.role}`}
89+ trailing={one.slug === workspace?.slug ? <Check size={18} color={colors.accent} /> : null}
90+ chevron={false}
91+ onPress={() => selectWorkspace(one.slug)}
92+ />
93+ ))}
94+ </View>
95+
96+ <View>
97+ <SectionHeader title="Signed in" />
98+ <Row
99+ title={oauth ? "With g1t" : "With an access token"}
100+ subtitle={oauth ? "End or narrow the app's access in Settings → Connected applications." : "The token you pasted. Delete it in Settings → Access tokens to end it."}
101+ trailing={<ExternalLink size={16} color={colors.faint} />}
102+ chevron={false}
103+ onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, oauth ? "/settings/applications" : "/settings/tokens"))}
104+ />
105+ </View>
106+
107+ <View style={{ paddingHorizontal: space.lg, gap: space.md }}>
108+ <Button title="Sign out" variant="danger" icon={LogOut} onPress={signOut} />
109+ <Text size="small" tone="faint" style={{ textAlign: "center" }}>
110+ g1t {Constants.expoConfig?.version ?? ""}
111+ </Text>
112+ </View>
113+ </Screen>
114+ );
115+}
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+15−0
1+import { Bell } from "lucide-react-native";
2+
3+import { ComingSoon } from "@/components/coming-soon";
4+
5+/** Notifications: reviews, approvals and alerts, your inbox. */
6+export default function NotificationsTab() {
7+ return (
8+ <ComingSoon
9+ title="Notifications"
10+ icon={Bell}
11+ body="Reviews, approvals and alerts. Messages to you already arrive as notifications on this phone."
12+ path={() => "/notifications"}
13+ />
14+ );
15+}
+24−0
1+import { routeFor, sitePathOf } from "@/lib/links";
2+
3+/**
4+ * Where a link that opens the app goes. `g1t://` links name a site path,
5+ * as on the desktop; the app shows the ones it has screens for and opens
6+ * the rest on the site (app/open.tsx). Android App Links and iOS
7+ * Universal Links for https://g1t.sh come through here too once the site
8+ * publishes assetlinks.json and apple-app-site-association.
9+ */
10+export function redirectSystemPath({ path }: { path: string; initial: boolean }): string {
11+ try {
12+ const sitePath = sitePathOf(path) ?? sitePathOfWeb(path) ?? path;
13+ const route = routeFor(sitePath);
14+ return route ?? `/open?path=${encodeURIComponent(sitePath)}`;
15+ } catch {
16+ return "/";
17+ }
18+}
19+
20+function sitePathOfWeb(link: string): string | null {
21+ if (!/^https?:\/\//i.test(link)) return null;
22+ const url = new URL(link);
23+ return url.pathname + url.search;
24+}
+22−0
1+import { Link, Stack } from "expo-router";
2+import { CircleHelp } from "lucide-react-native";
3+
4+import { Empty, Screen, Text } from "@/components/ui";
5+
6+export default function NotFound() {
7+ return (
8+ <Screen safe>
9+ <Stack.Screen options={{ headerShown: false }} />
10+ <Empty
11+ icon={CircleHelp}
12+ title="Nothing here"
13+ body="The app has no screen for this link."
14+ action={
15+ <Link href="/" replace>
16+ <Text tone="accent">Go to Chat</Text>
17+ </Link>
18+ }
19+ />
20+ </Screen>
21+ );
22+}
+68−0
1+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from "expo-router";
3+import * as SplashScreen from "expo-splash-screen";
4+import { StatusBar } from "expo-status-bar";
5+import { useEffect, useState } from "react";
6+import { useColorScheme } from "react-native";
7+
8+import { ApiError } from "@/lib/client";
9+import { SessionProvider, useSession } from "@/lib/session";
10+import { useColors } from "@/theme";
11+
12+void SplashScreen.preventAutoHideAsync();
13+
14+export default function RootLayout() {
15+ const [queries] = useState(
16+ () =>
17+ new QueryClient({
18+ defaultOptions: {
19+ queries: {
20+ staleTime: 30_000,
21+ // Signed out, not found and refused are answers, not hiccups.
22+ retry: (count, error) => !(error instanceof ApiError && error.code !== "offline") && count < 2,
23+ },
24+ },
25+ }),
26+ );
27+ return (
28+ <QueryClientProvider client={queries}>
29+ <SessionProvider>
30+ <Navigation />
31+ </SessionProvider>
32+ </QueryClientProvider>
33+ );
34+}
35+
36+function Navigation() {
37+ const { state } = useSession();
38+ const scheme = useColorScheme();
39+ const colors = useColors();
40+ const base = scheme === "light" ? DefaultTheme : DarkTheme;
41+ const theme = {
42+ ...base,
43+ colors: { ...base.colors, primary: colors.accent, background: colors.bg, card: colors.surface, text: colors.fg, border: colors.line },
44+ };
45+
46+ useEffect(() => {
47+ if (state.status !== "loading") void SplashScreen.hideAsync();
48+ }, [state.status]);
49+
50+ if (state.status === "loading") return null;
51+ const signedIn = state.status === "signed-in";
52+
53+ return (
54+ <ThemeProvider value={theme}>
55+ <StatusBar style={scheme === "light" ? "dark" : "light"} />
56+ <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
57+ <Stack.Protected guard={signedIn}>
58+ <Stack.Screen name="(app)" />
59+ </Stack.Protected>
60+ <Stack.Protected guard={!signedIn}>
61+ <Stack.Screen name="sign-in" />
62+ </Stack.Protected>
63+ <Stack.Screen name="oauth/callback" />
64+ <Stack.Screen name="open" />
65+ </Stack>
66+ </ThemeProvider>
67+ );
68+}
+22−0
1+import { Redirect } from "expo-router";
2+
3+import { Loading, Screen } from "@/components/ui";
4+import { useSession } from "@/lib/session";
5+
6+/**
7+ * Where the site sends the person back after they approve the app
8+ * (`g1t://oauth/callback`). The sign-in screen's browser session reads
9+ * the code from the address itself (lib/oauth.ts); this screen is only
10+ * what shows if the link opens the app on its own, and it moves on.
11+ */
12+export default function OAuthCallback() {
13+ const { state } = useSession();
14+ if (state.status === "loading") {
15+ return (
16+ <Screen>
17+ <Loading />
18+ </Screen>
19+ );
20+ }
21+ return <Redirect href={state.status === "signed-in" ? "/chat" : "/sign-in"} />;
22+}
+32−0
1+import { router, useLocalSearchParams } from "expo-router";
2+import * as WebBrowser from "expo-web-browser";
3+import { useEffect } from "react";
4+
5+import { Loading, Screen } from "@/components/ui";
6+import { useSession } from "@/lib/session";
7+import { siteUrl } from "@/lib/server";
8+
9+/**
10+ * A link to a page the app has no screen for: it opens on the site, in
11+ * the system's browser, and the app goes back to where it was.
12+ */
13+export default function Open() {
14+ const { path } = useLocalSearchParams<{ path?: string }>();
15+ const { state } = useSession();
16+
17+ useEffect(() => {
18+ const server = state.server;
19+ if (!server) return;
20+ const safe = path && path.startsWith("/") ? path : "/";
21+ void WebBrowser.openBrowserAsync(siteUrl(server, safe)).finally(() => {
22+ if (router.canGoBack()) router.back();
23+ else router.replace("/");
24+ });
25+ }, [path, state.server]);
26+
27+ return (
28+ <Screen>
29+ <Loading />
30+ </Screen>
31+ );
32+}
+124−0
1+import { KeyRound, LogIn, Server as ServerIcon } from "lucide-react-native";
2+import { useState } from "react";
3+import { KeyboardAvoidingView, Platform, View } from "react-native";
4+
5+import { Button, Field, Screen, Text } from "@/components/ui";
6+import { G1tMark } from "@/components/mark";
7+import { SignInCancelled } from "@/lib/oauth";
8+import { HOSTED, isHosted, parseServer } from "@/lib/server";
9+import { useSession } from "@/lib/session";
10+import { space } from "@/theme";
11+
12+/**
13+ * Signing in. The usual way is **Sign in with g1t**: the site opens in the
14+ * system's browser, the person approves the app, and it comes back signed
15+ * in (lib/oauth.ts). **Use an access token** takes a personal token
16+ * instead. **Server** points the app at a self-hosted g1t.
17+ */
18+export default function SignIn() {
19+ const { state, signIn, signInWithToken, changeServer } = useSession();
20+ const server = state.server ?? HOSTED;
21+ const [mode, setMode] = useState<"oauth" | "token" | "server">("oauth");
22+ const [busy, setBusy] = useState(false);
23+ const [error, setError] = useState<string | null>(null);
24+ const [token, setToken] = useState("");
25+ const [site, setSite] = useState(server.site);
26+ const [api, setApi] = useState(isHosted(server) ? "" : server.api);
27+
28+ const run = async (work: () => Promise<void>) => {
29+ setBusy(true);
30+ setError(null);
31+ try {
32+ await work();
33+ } catch (failure) {
34+ if (!(failure instanceof SignInCancelled)) setError(failure instanceof Error ? failure.message : "Sign-in failed.");
35+ } finally {
36+ setBusy(false);
37+ }
38+ };
39+
40+ return (
41+ <Screen safe>
42+ <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : undefined} style={{ flex: 1 }}>
43+ <View style={{ flex: 1, padding: space.xl, justifyContent: "center", gap: space.xl }}>
44+ <View style={{ gap: space.md }}>
45+ <G1tMark size={44} />
46+ <Text size="title">Sign in to g1t</Text>
47+ <Text tone="muted">Chat with your team and its agents, and review and merge what they make.</Text>
48+ </View>
49+
50+ {mode === "oauth" ? (
51+ <View style={{ gap: space.md }}>
52+ <Button title="Sign in with g1t" icon={LogIn} busy={busy} onPress={() => run(signIn)} />
53+ <Text size="small" tone="faint">
54+ {new URL(server.site).host} opens in your browser to approve the app. Your password never reaches it.
55+ </Text>
56+ </View>
57+ ) : null}
58+
59+ {mode === "token" ? (
60+ <View style={{ gap: space.md }}>
61+ <Field
62+ label="Access token"
63+ placeholder="g1t_…"
64+ value={token}
65+ onChangeText={setToken}
66+ secureTextEntry
67+ hint="Make one in Settings → Access tokens. For Chat, turn on “Use the website as you”."
68+ />
69+ <Button title="Sign in" busy={busy} disabled={!token.trim()} onPress={() => run(() => signInWithToken(token))} />
70+ </View>
71+ ) : null}
72+
73+ {mode === "server" ? (
74+ <View style={{ gap: space.md }}>
75+ <Field label="Site" placeholder="g1t.example.com" value={site} onChangeText={setSite} keyboardType="url" />
76+ <Field
77+ label="API (optional)"
78+ placeholder={`api.${site ? site.replace(/^https?:\/\//, "") : "g1t.example.com"}`}
79+ value={api}
80+ onChangeText={setApi}
81+ keyboardType="url"
82+ hint="Left empty, the API is api. in front of the site's address."
83+ />
84+ <Button
85+ title="Use this server"
86+ busy={busy}
87+ onPress={() =>
88+ run(async () => {
89+ const next = parseServer(site, api);
90+ if (!next) throw new Error("That is not a web address. It looks like https://g1t.example.com.");
91+ await changeServer(next);
92+ setMode("oauth");
93+ })
94+ }
95+ />
96+ <Button
97+ title="Use g1t.sh"
98+ variant="secondary"
99+ onPress={() => {
100+ setSite(HOSTED.site);
101+ setApi("");
102+ void run(async () => {
103+ await changeServer(HOSTED);
104+ setMode("oauth");
105+ });
106+ }}
107+ />
108+ </View>
109+ ) : null}
110+
111+ {error ? <Text tone="danger">{error}</Text> : null}
112+
113+ <View style={{ gap: space.sm }}>
114+ {mode !== "oauth" ? <Button title="Sign in with g1t instead" variant="secondary" icon={LogIn} onPress={() => setMode("oauth")} /> : null}
115+ {mode !== "token" ? <Button title="Use an access token" variant="secondary" icon={KeyRound} onPress={() => setMode("token")} /> : null}
116+ {mode !== "server" ? (
117+ <Button title={isHosted(server) ? "Server: g1t.sh" : `Server: ${new URL(server.site).host}`} variant="secondary" icon={ServerIcon} onPress={() => setMode("server")} />
118+ ) : null}
119+ </View>
120+ </View>
121+ </KeyboardAvoidingView>
122+ </Screen>
123+ );
124+}
+81−0
1+import { router } from "expo-router";
2+import { X } from "lucide-react-native";
3+import { useEffect, useRef } from "react";
4+import { Animated, Pressable, StyleSheet, View } from "react-native";
5+import { useSafeAreaInsets } from "react-native-safe-area-context";
6+
7+import { Avatar, Text } from "@/components/ui";
8+import { useFeed } from "@/lib/feed";
9+import { routeFor } from "@/lib/links";
10+import { useSignedIn } from "@/lib/session";
11+import { radius, space, useColors } from "@/theme";
12+
13+/**
14+ * A notification while the app is open, as the site's toast: who, what,
15+ * and a tap to go there. Not shown for the conversation on screen.
16+ */
17+export function Banner() {
18+ const { banner, dismiss } = useFeed();
19+ const { server } = useSignedIn();
20+ const colors = useColors();
21+ const insets = useSafeAreaInsets();
22+ const offset = useRef(new Animated.Value(-160)).current;
23+
24+ useEffect(() => {
25+ Animated.spring(offset, { toValue: banner ? 0 : -160, useNativeDriver: true, speed: 18, bounciness: 6 }).start();
26+ }, [banner, offset]);
27+
28+ if (!banner) return null;
29+ const go = () => {
30+ dismiss();
31+ if (banner.channel_id && banner.thread_root) router.push({ pathname: "/chat/thread", params: { channel: banner.channel_id, root: banner.thread_root } });
32+ else if (banner.channel_id) router.push({ pathname: "/chat/[channel]", params: { channel: banner.channel_id } });
33+ else {
34+ const route = routeFor(banner.href);
35+ router.push((route ?? `/open?path=${encodeURIComponent(banner.href)}`) as never);
36+ }
37+ };
38+
39+ return (
40+ <Animated.View pointerEvents="box-none" style={[styles.wrap, { top: insets.top + space.sm, transform: [{ translateY: offset }] }]}>
41+ <Pressable onPress={go} style={[styles.banner, { backgroundColor: colors.raised, borderColor: colors.lineStrong }]}>
42+ <Avatar
43+ name={banner.actor.name}
44+ uri={banner.actor.avatar ? `${server.site}/avatars/${banner.actor.avatar}` : null}
45+ agent={banner.actor.kind === "agent"}
46+ size={36}
47+ />
48+ <View style={{ flex: 1 }}>
49+ <Text style={{ fontWeight: "700" }} numberOfLines={1}>
50+ {banner.title}
51+ </Text>
52+ {banner.body ? (
53+ <Text size="small" tone="muted" numberOfLines={2}>
54+ {banner.body}
55+ </Text>
56+ ) : null}
57+ </View>
58+ <Pressable accessibilityLabel="Dismiss" hitSlop={10} onPress={dismiss}>
59+ <X size={18} color={colors.faint} />
60+ </Pressable>
61+ </Pressable>
62+ </Animated.View>
63+ );
64+}
65+
66+const styles = StyleSheet.create({
67+ wrap: { position: "absolute", left: space.md, right: space.md },
68+ banner: {
69+ flexDirection: "row",
70+ alignItems: "center",
71+ gap: space.md,
72+ padding: space.md,
73+ borderRadius: radius.lg,
74+ borderWidth: StyleSheet.hairlineWidth,
75+ shadowColor: "#000",
76+ shadowOpacity: 0.3,
77+ shadowRadius: 12,
78+ shadowOffset: { width: 0, height: 4 },
79+ elevation: 8,
80+ },
81+});
+86−0
1+import * as Haptics from "expo-haptics";
2+import { ArrowUp } from "lucide-react-native";
3+import { useEffect, useState } from "react";
4+import { Keyboard, Pressable, StyleSheet, TextInput, View } from "react-native";
5+import { useSafeAreaInsets } from "react-native-safe-area-context";
6+
7+import { radius, space, useColors } from "@/theme";
8+
9+/** What was typed and not sent, per conversation, while the app runs. */
10+const drafts = new Map<string, string>();
11+
12+/**
13+ * The box at the bottom of a conversation. Enter makes a new line, as on
14+ * a phone; the arrow sends. Typing tells the others (at most every 3 s).
15+ */
16+export function Composer({
17+ draftKey,
18+ placeholder,
19+ onSend,
20+ onTyping,
21+ onFocus,
22+}: {
23+ draftKey: string;
24+ placeholder: string;
25+ onSend: (body: string) => void;
26+ onTyping: () => void;
27+ /** The box was tapped into: the conversation goes to its newest message. */
28+ onFocus?: () => void;
29+}) {
30+ const colors = useColors();
31+ const insets = useSafeAreaInsets();
32+ const [text, setText] = useState(() => drafts.get(draftKey) ?? "");
33+ const ready = text.trim().length > 0;
34+ // With the keyboard up the box sits on it; the home indicator's room is only for when it is down.
35+ const [keyboard, setKeyboard] = useState(false);
36+ useEffect(() => {
37+ const shown = Keyboard.addListener("keyboardDidShow", () => setKeyboard(true));
38+ const hidden = Keyboard.addListener("keyboardDidHide", () => setKeyboard(false));
39+ return () => {
40+ shown.remove();
41+ hidden.remove();
42+ };
43+ }, []);
44+
45+ const send = () => {
46+ const body = text.trim();
47+ if (!body) return;
48+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined);
49+ onSend(body);
50+ setText("");
51+ drafts.delete(draftKey);
52+ };
53+
54+ return (
55+ <View style={[styles.bar, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: keyboard ? space.sm : Math.max(insets.bottom, space.sm) }]}>
56+ <TextInput
57+ value={text}
58+ onChangeText={(next) => {
59+ setText(next);
60+ drafts.set(draftKey, next);
61+ if (next.length > 0) onTyping();
62+ }}
63+ placeholder={placeholder}
64+ placeholderTextColor={colors.faint}
65+ onFocus={onFocus}
66+ multiline
67+ style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]}
68+ />
69+ <Pressable
70+ accessibilityRole="button"
71+ accessibilityLabel="Send"
72+ onPress={send}
73+ disabled={!ready}
74+ style={[styles.send, { backgroundColor: ready ? colors.accent : colors.raised }]}
75+ >
76+ <ArrowUp size={20} color={ready ? colors.onAccent : colors.faint} strokeWidth={2.5} />
77+ </Pressable>
78+ </View>
79+ );
80+}
81+
82+const styles = StyleSheet.create({
83+ bar: { flexDirection: "row", alignItems: "flex-end", gap: space.sm, paddingHorizontal: space.md, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth },
84+ input: { flex: 1, minHeight: 40, maxHeight: 160, borderRadius: radius.lg, borderWidth: 1, paddingHorizontal: space.md, paddingTop: 10, paddingBottom: 10, fontSize: 15 },
85+ send: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center" },
86+});
+296−0
1+import type { CardAction, ChatMessage } from "@g1t/contracts";
2+import { useQueryClient } from "@tanstack/react-query";
3+import * as Clipboard from "expo-clipboard";
4+import { router, useFocusEffect } from "expo-router";
5+import { useHeaderHeight } from "expo-router/react-navigation";
6+import { ArrowDown, Copy, MessageSquareReply, Pencil, Trash2 } from "lucide-react-native";
7+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8+import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Pressable, StyleSheet, View } from "react-native";
9+
10+import { Composer } from "@/components/chat/composer";
11+import { Message, runsOn } from "@/components/chat/message";
12+import { PromptSheet, Sheet, SheetAction } from "@/components/chat/sheet";
13+import { ErrorView, Loading, Text } from "@/components/ui";
14+import { type Shown, markRead, useChannelLive, useChatActions, useMessages, useSend } from "@/lib/chat";
15+import { useSignedIn } from "@/lib/session";
16+import { viewing } from "@/lib/viewing";
17+import { radius, space, useColors } from "@/theme";
18+
19+const QUICK = ["👍", "❤️", "😂", "🎉", "👀", "✅"];
20+
21+type Row = { kind: "message"; message: Shown; head: boolean } | { kind: "day"; key: string; label: string };
22+
23+/**
24+ * A conversation, or one thread of it: messages newest at the bottom, live
25+ * while open, read as they arrive, and a box to write in. Long-press a
26+ * message to react, reply in a thread, copy, or change or delete your own.
27+ */
28+export function Conversation({ channelId, thread, title }: { channelId: string; thread: string | null; title: string }) {
29+ const colors = useColors();
30+ const queries = useQueryClient();
31+ const { client, workspace, me } = useSignedIn();
32+ const slug = workspace?.slug ?? "";
33+ const messages = useMessages(channelId, thread);
34+ const live = useChannelLive(channelId);
35+ const send = useSend(channelId, thread);
36+ const actions = useChatActions(channelId);
37+ const [picked, setPicked] = useState<Shown | null>(null);
38+ const [editing, setEditing] = useState<Shown | null>(null);
39+ const [scrolledUp, setScrolledUp] = useState(false);
40+ const list = useRef<FlatList<Row>>(null);
41+ const focused = useRef(false);
42+ const headerHeight = useHeaderHeight();
43+
44+ // While on screen, this conversation's banners and pushes are not shown.
45+ useFocusEffect(
46+ useCallback(() => {
47+ focused.current = true;
48+ viewing.channelId = channelId;
49+ viewing.threadRoot = thread;
50+ return () => {
51+ focused.current = false;
52+ if (viewing.channelId === channelId) {
53+ viewing.channelId = null;
54+ viewing.threadRoot = null;
55+ }
56+ };
57+ }, [channelId, thread]),
58+ );
59+
60+ const all = useMemo(() => {
61+ const pages = messages.data?.pages ?? [];
62+ // Oldest first: the last page read is the oldest.
63+ const ordered = [...pages].reverse().flatMap((page) => page.messages as Shown[]);
64+ // A thread's first page carries the message it is under.
65+ const root = thread ? (pages[0]?.root ?? null) : null;
66+ const shown = ordered.filter((message) => !message.deleted_at && message.id !== root?.id);
67+ return root ? [root as Shown, ...shown] : shown;
68+ }, [messages.data, thread]);
69+
70+ // Rows newest first, for a list drawn from the bottom up, with the day between days.
71+ const rows = useMemo(() => {
72+ const out: Row[] = [];
73+ let day = "";
74+ all.forEach((message, index) => {
75+ const date = new Date(message.created_at);
76+ const label = dayLabel(date);
77+ if (label !== day) {
78+ out.push({ kind: "day", key: `day-${label}-${index}`, label });
79+ day = label;
80+ }
81+ out.push({ kind: "message", message, head: label !== dayLabel(new Date(all[index - 1]?.created_at ?? 0)) || !runsOn(all[index - 1], message) });
82+ });
83+ return out.reverse();
84+ }, [all]);
85+
86+ // Read up to the newest while it is on screen.
87+ const newest = all.filter((message) => !message.local).at(-1)?.id;
88+ useEffect(() => {
89+ if (slug && newest && focused.current && !thread) void markRead(client, queries, slug, channelId, newest);
90+ }, [client, queries, slug, channelId, newest, thread]);
91+
92+ const onCardAction = useCallback(
93+ async (message: ChatMessage, action: CardAction, input?: string | null) => {
94+ const answer = await actions.cardAction.mutateAsync({ message, action: action.id, input });
95+ if (!answer.ok) throw new Error(answer.message ?? "That didn't work.");
96+ return answer.message;
97+ },
98+ [actions.cardAction],
99+ );
100+
101+ const onReact = useCallback((message: ChatMessage, emoji: string, add: boolean) => actions.react.mutate({ message, emoji, add }), [actions.react]);
102+ const onRetry = useCallback((message: Shown) => send(message.body, message), [send]);
103+
104+ const mine = picked?.author.kind === "user" && picked.author.id === me.id;
105+ const typingLine = typingText(live.typing.map((member) => member.display_name));
106+
107+ return (
108+ // The keyboard pushes the composer up and the list shrinks from the top, on
109+ // Android too: apps draw edge to edge there, so the window no longer resizes
110+ // for it. The list is drawn from the bottom, so the newest stays in view.
111+ <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}>
112+ <View style={{ flex: 1 }}>
113+ {messages.isPending ? (
114+ <Loading />
115+ ) : messages.error ? (
116+ <ErrorView error={messages.error} retry={() => messages.refetch()} />
117+ ) : (
118+ <FlatList
119+ ref={list}
120+ inverted
121+ data={rows}
122+ keyExtractor={(row) => (row.kind === "day" ? row.key : row.message.id)}
123+ renderItem={({ item }) =>
124+ item.kind === "day" ? (
125+ <View style={styles.day}>
126+ <View style={[styles.dayLine, { backgroundColor: colors.line }]} />
127+ <Text size="small" tone="faint" style={{ fontWeight: "600" }}>
128+ {item.label}
129+ </Text>
130+ <View style={[styles.dayLine, { backgroundColor: colors.line }]} />
131+ </View>
132+ ) : (
133+ <Message
134+ message={item.message}
135+ head={item.head}
136+ inThread={!!thread}
137+ onLongPress={setPicked}
138+ onReact={onReact}
139+ onRetry={onRetry}
140+ onCardAction={onCardAction}
141+ />
142+ )
143+ }
144+ onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()}
145+ onEndReachedThreshold={0.6}
146+ onScroll={(event) => setScrolledUp(event.nativeEvent.contentOffset.y > 600)}
147+ scrollEventThrottle={100}
148+ ListHeaderComponent={<View style={{ height: space.sm }} />}
149+ ListFooterComponent={
150+ messages.isFetchingNextPage ? (
151+ <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} />
152+ ) : !messages.hasNextPage ? (
153+ <Text size="small" tone="faint" style={{ textAlign: "center", padding: space.xl }}>
154+ {thread ? "The start of this thread." : `The start of ${title}.`}
155+ </Text>
156+ ) : null
157+ }
158+ keyboardDismissMode="interactive"
159+ keyboardShouldPersistTaps="handled"
160+ />
161+ )}
162+ {scrolledUp ? (
163+ <Pressable
164+ accessibilityLabel="Go to newest"
165+ onPress={() => list.current?.scrollToOffset({ offset: 0, animated: true })}
166+ style={[styles.jump, { backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
167+ >
168+ <ArrowDown size={18} color={colors.fg} />
169+ </Pressable>
170+ ) : null}
171+ </View>
172+ <View style={styles.typing}>
173+ <Text size="small" tone="faint" numberOfLines={1}>
174+ {typingLine}
175+ </Text>
176+ {live.status !== "live" ? (
177+ <Text size="small" tone="faint">
178+ {live.status === "connecting" ? "Connecting…" : "Offline, trying again"}
179+ </Text>
180+ ) : null}
181+ </View>
182+ <Composer
183+ draftKey={`${channelId}:${thread ?? ""}`}
184+ placeholder={thread ? "Reply in thread" : `Message ${title}`}
185+ onSend={(body) => {
186+ send(body);
187+ list.current?.scrollToOffset({ offset: 0, animated: true });
188+ }}
189+ onTyping={live.sayTyping}
190+ onFocus={() => list.current?.scrollToOffset({ offset: 0, animated: true })}
191+ />
192+
193+ <Sheet visible={!!picked} onClose={() => setPicked(null)}>
194+ <View style={styles.quick}>
195+ {QUICK.map((emoji) => {
196+ const had = picked?.reactions?.find((one) => one.emoji === emoji)?.me ?? false;
197+ return (
198+ <Pressable
199+ key={emoji}
200+ onPress={() => {
201+ if (picked) onReact(picked, emoji, !had);
202+ setPicked(null);
203+ }}
204+ style={[styles.quickOne, { backgroundColor: had ? colors.accentLow : colors.raised }]}
205+ >
206+ <Text style={{ fontSize: 24, lineHeight: 30 }}>{emoji}</Text>
207+ </Pressable>
208+ );
209+ })}
210+ </View>
211+ {!thread ? (
212+ <SheetAction
213+ icon={MessageSquareReply}
214+ label="Reply in thread"
215+ onPress={() => {
216+ const message = picked;
217+ setPicked(null);
218+ if (message) router.push({ pathname: "/chat/thread", params: { channel: channelId, root: message.id } });
219+ }}
220+ />
221+ ) : null}
222+ <SheetAction
223+ icon={Copy}
224+ label="Copy text"
225+ onPress={() => {
226+ if (picked?.body) void Clipboard.setStringAsync(picked.body);
227+ setPicked(null);
228+ }}
229+ />
230+ {mine && picked?.kind === "text" ? (
231+ <SheetAction
232+ icon={Pencil}
233+ label="Edit"
234+ onPress={() => {
235+ setEditing(picked);
236+ setPicked(null);
237+ }}
238+ />
239+ ) : null}
240+ {mine ? (
241+ <SheetAction
242+ icon={Trash2}
243+ label="Delete"
244+ danger
245+ onPress={() => {
246+ const message = picked;
247+ setPicked(null);
248+ if (!message) return;
249+ Alert.alert("Delete this message?", "It is gone for everyone in the conversation.", [
250+ { text: "Cancel", style: "cancel" },
251+ { text: "Delete", style: "destructive", onPress: () => actions.remove.mutate(message) },
252+ ]);
253+ }}
254+ />
255+ ) : null}
256+ </Sheet>
257+
258+ <PromptSheet
259+ visible={!!editing}
260+ title="Edit message"
261+ initial={editing?.body ?? ""}
262+ multiline
263+ confirm="Save"
264+ onCancel={() => setEditing(null)}
265+ onDone={(body) => {
266+ if (editing && body !== editing.body) actions.edit.mutate({ message: editing, body });
267+ setEditing(null);
268+ }}
269+ />
270+ </KeyboardAvoidingView>
271+ );
272+}
273+
274+function dayLabel(date: Date): string {
275+ const today = new Date();
276+ const yesterday = new Date(today.getTime() - 86_400_000);
277+ if (date.toDateString() === today.toDateString()) return "Today";
278+ if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
279+ return date.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", ...(date.getFullYear() !== today.getFullYear() ? { year: "numeric" } : {}) });
280+}
281+
282+function typingText(names: string[]): string {
283+ if (names.length === 0) return "";
284+ if (names.length === 1) return `${names[0]} is typing…`;
285+ if (names.length === 2) return `${names[0]} and ${names[1]} are typing…`;
286+ return "Several people are typing…";
287+}
288+
289+const styles = StyleSheet.create({
290+ day: { flexDirection: "row", alignItems: "center", gap: space.sm, paddingHorizontal: space.lg, paddingVertical: space.md },
291+ dayLine: { flex: 1, height: StyleSheet.hairlineWidth },
292+ jump: { position: "absolute", right: space.lg, bottom: space.md, width: 40, height: 40, borderRadius: 20, borderWidth: 1, alignItems: "center", justifyContent: "center" },
293+ typing: { flexDirection: "row", justifyContent: "space-between", paddingHorizontal: space.lg, minHeight: 18 },
294+ quick: { flexDirection: "row", justifyContent: "space-around", paddingHorizontal: space.md, paddingBottom: space.sm },
295+ quickOne: { width: 48, height: 48, borderRadius: radius.full, alignItems: "center", justifyContent: "center" },
296+});
+186−0
1+import { type Block, type Span, blocks } from "@g1t/contracts/chat-markdown";
2+import { router } from "expo-router";
3+import * as WebBrowser from "expo-web-browser";
4+import { Fragment, useMemo } from "react";
5+import { ScrollView, StyleSheet, Text, View } from "react-native";
6+
7+import { routeFor } from "@/lib/links";
8+import { siteUrl } from "@/lib/server";
9+import { useSignedIn } from "@/lib/session";
10+import { fonts, radius, space, useColors } from "@/theme";
11+
12+/**
13+ * A message's Markdown, as the site draws it (components/chat/text.tsx),
14+ * from the same parser (@g1t/contracts/chat-markdown): nothing in a
15+ * message is HTML, links go only to the web, mail or a page on g1t, and
16+ * `@mentions`, `#channels` and `#123` references stand out. A link to a
17+ * page the app has opens there; any other opens in the browser.
18+ */
19+export function Markdown({ text, muted }: { text: string; muted?: boolean }) {
20+ const parsed = useMemo(() => blocks(text), [text]);
21+ return (
22+ <View style={{ gap: space.xs }}>
23+ {parsed.map((block, index) => (
24+ <BlockView key={index} block={block} muted={muted} />
25+ ))}
26+ </View>
27+ );
28+}
29+
30+function useOpen() {
31+ const { server, workspace } = useSignedIn();
32+ return (href: string) => {
33+ const path = href.startsWith(server.site) ? href.slice(server.site.length) || "/" : href.startsWith("/") ? href : null;
34+ const route = path ? routeFor(path) : null;
35+ if (route) return router.push(route as never);
36+ void WebBrowser.openBrowserAsync(path ? siteUrl(server, path) : href);
37+ void workspace;
38+ };
39+}
40+
41+function BlockView({ block, muted }: { block: Block; muted?: boolean }) {
42+ const colors = useColors();
43+ const base = { color: muted ? colors.muted : colors.fg, fontSize: 15, lineHeight: 21 };
44+ switch (block.t) {
45+ case "p":
46+ return (
47+ <Text style={base} selectable>
48+ {block.lines.map((line, index) => (
49+ <Fragment key={index}>
50+ {index > 0 ? "\n" : null}
51+ <Spans spans={line} />
52+ </Fragment>
53+ ))}
54+ </Text>
55+ );
56+ case "heading":
57+ return (
58+ <Text style={[base, { fontWeight: "700", fontSize: block.level <= 2 ? 17 : 15 }]}>
59+ <Spans spans={block.c} />
60+ </Text>
61+ );
62+ case "code":
63+ return (
64+ <ScrollView horizontal style={[styles.codeBlock, { backgroundColor: colors.raised, borderColor: colors.line }]}>
65+ <Text selectable style={{ fontFamily: fonts.mono, fontSize: 13, lineHeight: 19, color: colors.fgSoft }}>
66+ {block.v}
67+ </Text>
68+ </ScrollView>
69+ );
70+ case "list":
71+ return (
72+ <View style={{ gap: 2 }}>
73+ {block.items.map((item, index) => (
74+ <View key={index} style={{ flexDirection: "row", gap: space.sm }}>
75+ <Text style={[base, { color: colors.muted, minWidth: 16 }]}>{block.ordered ? `${block.start + index}.` : "•"}</Text>
76+ <View style={{ flex: 1, gap: 2 }}>
77+ {item.map((inner, at) => (
78+ <BlockView key={at} block={inner} muted={muted} />
79+ ))}
80+ </View>
81+ </View>
82+ ))}
83+ </View>
84+ );
85+ case "quote":
86+ return (
87+ <View style={[styles.quote, { borderLeftColor: colors.lineStrong }]}>
88+ {block.c.map((inner, index) => (
89+ <BlockView key={index} block={inner} muted />
90+ ))}
91+ </View>
92+ );
93+ case "hr":
94+ return <View style={{ height: StyleSheet.hairlineWidth, backgroundColor: colors.lineStrong, marginVertical: space.xs }} />;
95+ case "table":
96+ return (
97+ <ScrollView horizontal>
98+ <View style={[styles.table, { borderColor: colors.line }]}>
99+ {[block.head, ...block.rows].map((row, rowIndex) => (
100+ <View key={rowIndex} style={{ flexDirection: "row", backgroundColor: rowIndex === 0 ? colors.raised : "transparent" }}>
101+ {row.map((cell, cellIndex) => (
102+ <Text
103+ key={cellIndex}
104+ style={[base, styles.cell, { borderColor: colors.line, fontWeight: rowIndex === 0 ? "600" : "400", textAlign: block.align[cellIndex] ?? "left" }]}
105+ >
106+ <Spans spans={cell} />
107+ </Text>
108+ ))}
109+ </View>
110+ ))}
111+ </View>
112+ </ScrollView>
113+ );
114+ }
115+}
116+
117+function Spans({ spans }: { spans: Span[] }) {
118+ const colors = useColors();
119+ const open = useOpen();
120+ const { workspace } = useSignedIn();
121+ return (
122+ <>
123+ {spans.map((span, index) => {
124+ switch (span.t) {
125+ case "text":
126+ return <Fragment key={index}>{span.v}</Fragment>;
127+ case "code":
128+ return (
129+ <Text key={index} style={{ fontFamily: fonts.mono, fontSize: 13, backgroundColor: colors.raised, color: colors.fgSoft }}>
130+ {` ${span.v} `}
131+ </Text>
132+ );
133+ case "strong":
134+ return (
135+ <Text key={index} style={{ fontWeight: "700" }}>
136+ <Spans spans={span.c} />
137+ </Text>
138+ );
139+ case "em":
140+ return (
141+ <Text key={index} style={{ fontStyle: "italic" }}>
142+ <Spans spans={span.c} />
143+ </Text>
144+ );
145+ case "del":
146+ return (
147+ <Text key={index} style={{ textDecorationLine: "line-through" }}>
148+ <Spans spans={span.c} />
149+ </Text>
150+ );
151+ case "link":
152+ return (
153+ <Text key={index} style={{ color: colors.accent, textDecorationLine: "underline" }} onPress={() => open(span.href)}>
154+ <Spans spans={span.c} />
155+ </Text>
156+ );
157+ case "mention":
158+ return (
159+ <Text key={index} style={{ color: colors.accent, backgroundColor: colors.accentLow, fontWeight: "600" }}>
160+ @{span.name}
161+ </Text>
162+ );
163+ case "channel":
164+ return (
165+ <Text key={index} style={{ color: colors.accent, fontWeight: "600" }} onPress={() => workspace && open(`/${workspace.slug}/-/chat/${span.name}`)}>
166+ #{span.name}
167+ </Text>
168+ );
169+ case "ref":
170+ return (
171+ <Text key={index} style={{ color: colors.accent }} onPress={() => span.repo && open(`/${span.repo}/issues/${span.number}`)}>
172+ {span.repo ? `${span.repo}#${span.number}` : `#${span.number}`}
173+ </Text>
174+ );
175+ }
176+ })}
177+ </>
178+ );
179+}
180+
181+const styles = StyleSheet.create({
182+ codeBlock: { borderWidth: 1, borderRadius: radius.sm, padding: space.sm },
183+ quote: { borderLeftWidth: 3, paddingLeft: space.sm, gap: space.xs },
184+ table: { borderWidth: 1, borderRadius: radius.sm },
185+ cell: { paddingHorizontal: space.sm, paddingVertical: 4, minWidth: 80, borderRightWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth },
186+});
+289−0
1+import type { CardAction, ChatMessage, MessageCard } from "@g1t/contracts";
2+import * as Haptics from "expo-haptics";
3+import { router } from "expo-router";
4+import * as WebBrowser from "expo-web-browser";
5+import { AlertCircle, Clock, MessageSquareReply } from "lucide-react-native";
6+import { memo, useState } from "react";
7+import { ActivityIndicator, Alert, Pressable, StyleSheet, View } from "react-native";
8+
9+import { Markdown } from "@/components/chat/markdown";
10+import { PromptSheet } from "@/components/chat/sheet";
11+import { Avatar, Badge, Text } from "@/components/ui";
12+import type { Shown } from "@/lib/chat";
13+import { routeFor } from "@/lib/links";
14+import { siteUrl } from "@/lib/server";
15+import { useSignedIn } from "@/lib/session";
16+import { clock } from "@/lib/time";
17+import { radius, space, useColors } from "@/theme";
18+
19+type Props = {
20+ message: Shown;
21+ /** The first of a run by one author: draws the avatar and name. */
22+ head: boolean;
23+ /** In a thread: no "replies" line under it. */
24+ inThread?: boolean;
25+ onLongPress: (message: Shown) => void;
26+ onReact: (message: ChatMessage, emoji: string, add: boolean) => void;
27+ onRetry: (message: Shown) => void;
28+ onCardAction: (message: ChatMessage, action: CardAction, input?: string | null) => Promise<string | null>;
29+};
30+
31+/**
32+ * One message in a conversation. Messages by the same author within a few
33+ * minutes run together under one name, as on the site; an agent carries
34+ * its marker and an Agent badge, and its cards' buttons work here.
35+ */
36+export const Message = memo(function Message({ message, head, inThread, onLongPress, onReact, onRetry, onCardAction }: Props) {
37+ const colors = useColors();
38+ const { server } = useSignedIn();
39+ const { author } = message;
40+ const agent = author.kind === "agent";
41+ const failed = message.local === "failed";
42+
43+ const openThread = () =>
44+ router.push({ pathname: "/chat/thread", params: { channel: message.channel_id, root: message.id } });
45+
46+ return (
47+ <Pressable
48+ onLongPress={() => {
49+ if (message.local) return;
50+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => undefined);
51+ onLongPress(message);
52+ }}
53+ onPress={failed ? () => onRetry(message) : undefined}
54+ delayLongPress={300}
55+ style={({ pressed }) => [styles.row, { paddingTop: head ? space.md : 2, backgroundColor: pressed ? colors.raised : "transparent" }]}
56+ >
57+ <View style={styles.gutter}>
58+ {head ? (
59+ <Avatar name={author.display_name} uri={author.avatar ? `${server.site}/avatars/${author.avatar}` : null} agent={agent} size={36} />
60+ ) : null}
61+ </View>
62+ <View style={{ flex: 1, gap: 4, opacity: message.local === "sending" ? 0.6 : 1 }}>
63+ {head ? (
64+ <View style={styles.header}>
65+ <Text style={{ fontWeight: "700" }} numberOfLines={1}>
66+ {author.display_name}
67+ </Text>
68+ {agent ? <Badge label={author.title ?? "Agent"} tone="accent" /> : null}
69+ <Text size="small" tone="faint">
70+ {clock(message.created_at)}
71+ </Text>
72+ </View>
73+ ) : null}
74+ {message.body ? <Markdown text={message.body} /> : null}
75+ {message.edited_at ? (
76+ <Text size="small" tone="faint">
77+ (edited)
78+ </Text>
79+ ) : null}
80+ {message.kind === "card" && message.card ? <Card card={message.card} message={message} onAction={onCardAction} site={server.site} /> : null}
81+ {message.reactions && message.reactions.length > 0 ? (
82+ <View style={styles.reactions}>
83+ {message.reactions.map((reaction) => (
84+ <Pressable
85+ key={reaction.emoji}
86+ accessibilityLabel={`${reaction.emoji} ${reaction.count}${reaction.me ? ", yours" : ""}`}
87+ onPress={() => {
88+ void Haptics.selectionAsync().catch(() => undefined);
89+ onReact(message, reaction.emoji, !reaction.me);
90+ }}
91+ style={[
92+ styles.reaction,
93+ { borderColor: reaction.me ? colors.accent : colors.line, backgroundColor: reaction.me ? colors.accentLow : colors.surface },
94+ ]}
95+ >
96+ <Text size="small">{reaction.emoji}</Text>
97+ <Text size="small" style={{ color: reaction.me ? colors.accent : colors.muted, fontWeight: "600" }}>
98+ {reaction.count}
99+ </Text>
100+ </Pressable>
101+ ))}
102+ </View>
103+ ) : null}
104+ {!inThread && message.reply_count > 0 ? (
105+ <Pressable onPress={openThread} style={styles.replies} hitSlop={8}>
106+ <MessageSquareReply size={14} color={colors.accent} />
107+ <Text size="small" tone="accent" style={{ fontWeight: "600" }}>
108+ {message.reply_count === 1 ? "1 reply" : `${message.reply_count} replies`}
109+ </Text>
110+ </Pressable>
111+ ) : null}
112+ {message.local === "sending" ? (
113+ <View style={styles.status}>
114+ <Clock size={12} color={colors.faint} />
115+ <Text size="small" tone="faint">
116+ Sending
117+ </Text>
118+ </View>
119+ ) : failed ? (
120+ <View style={styles.status}>
121+ <AlertCircle size={12} color={colors.danger} />
122+ <Text size="small" tone="danger">
123+ Not sent. Tap to try again.
124+ </Text>
125+ </View>
126+ ) : null}
127+ </View>
128+ </Pressable>
129+ );
130+});
131+
132+/**
133+ * An agent's card: what it is about, its state, and the buttons that act
134+ * on it (approve more budget, stop a session, file a draft issue). A button
135+ * may ask first, or ask for a value; its owner answers, and the card
136+ * updates in place through the live socket.
137+ */
138+function Card({
139+ card,
140+ message,
141+ onAction,
142+ site,
143+}: {
144+ card: MessageCard;
145+ message: ChatMessage;
146+ onAction: Props["onCardAction"];
147+ site: string;
148+}) {
149+ const colors = useColors();
150+ const [busy, setBusy] = useState<string | null>(null);
151+ const [asking, setAsking] = useState<CardAction | null>(null);
152+ const [said, setSaid] = useState<string | null>(null);
153+
154+ const open = (href: string) => {
155+ const route = href.startsWith("/") ? routeFor(href) : null;
156+ if (route) router.push(route as never);
157+ else void WebBrowser.openBrowserAsync(href.startsWith("/") ? siteUrl({ site, api: "" }, href) : href);
158+ };
159+
160+ const run = async (action: CardAction, input?: string | null) => {
161+ setBusy(action.id);
162+ setSaid(null);
163+ try {
164+ const answer = await onAction(message, action, input);
165+ if (answer) setSaid(answer);
166+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => undefined);
167+ } catch (error) {
168+ setSaid(error instanceof Error ? error.message : "That didn't work.");
169+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => undefined);
170+ } finally {
171+ setBusy(null);
172+ }
173+ };
174+
175+ const press = (action: CardAction) => {
176+ if (action.href) return open(action.href);
177+ if (action.input) return setAsking(action);
178+ if (action.confirm) {
179+ return Alert.alert(action.label, action.confirm, [
180+ { text: "Cancel", style: "cancel" },
181+ { text: action.label, style: action.style === "danger" ? "destructive" : "default", onPress: () => void run(action) },
182+ ]);
183+ }
184+ void run(action);
185+ };
186+
187+ return (
188+ <View style={[styles.card, { borderColor: colors.lineStrong, backgroundColor: colors.surface }]}>
189+ <Pressable disabled={!card.href} onPress={() => card.href && open(card.href)} style={{ gap: 4 }}>
190+ <View style={{ flexDirection: "row", gap: space.sm, alignItems: "flex-start" }}>
191+ <Text style={{ flex: 1, fontWeight: "700" }}>{card.title}</Text>
192+ {card.state ? <Badge label={card.state} tone={/fail|stop|denied|error/i.test(card.state) ? "danger" : /merged|done|approved|passed/i.test(card.state) ? "success" : "accent"} /> : null}
193+ </View>
194+ {card.detail ? (
195+ <Text size="small" tone="muted">
196+ {card.detail}
197+ </Text>
198+ ) : null}
199+ {card.body ? <Markdown text={card.body} muted /> : null}
200+ {card.fields?.length ? (
201+ <View style={styles.fields}>
202+ {card.fields.map((field) => (
203+ <View key={field.label} style={{ width: "48%" }}>
204+ <Text size="small" tone="faint">
205+ {field.label}
206+ </Text>
207+ <Text size="small" numberOfLines={2}>
208+ {field.value}
209+ </Text>
210+ </View>
211+ ))}
212+ </View>
213+ ) : null}
214+ </Pressable>
215+ {card.actions?.length ? (
216+ <View style={styles.actions}>
217+ {card.actions.map((action) => {
218+ const primary = action.style === "primary";
219+ const danger = action.style === "danger";
220+ return (
221+ <Pressable
222+ key={action.id}
223+ disabled={!!busy}
224+ onPress={() => press(action)}
225+ style={({ pressed }) => [
226+ styles.action,
227+ {
228+ backgroundColor: primary ? colors.accent : colors.bg,
229+ borderColor: primary ? colors.accent : danger ? colors.danger : colors.lineStrong,
230+ opacity: pressed || (busy && busy !== action.id) ? 0.6 : 1,
231+ },
232+ ]}
233+ >
234+ {busy === action.id ? (
235+ <ActivityIndicator size="small" color={primary ? colors.onAccent : colors.accent} />
236+ ) : (
237+ <Text size="small" style={{ fontWeight: "700", color: primary ? colors.onAccent : danger ? colors.danger : colors.fg }}>
238+ {action.label}
239+ </Text>
240+ )}
241+ </Pressable>
242+ );
243+ })}
244+ </View>
245+ ) : null}
246+ {said ? (
247+ <Text size="small" tone="muted">
248+ {said}
249+ </Text>
250+ ) : null}
251+ <PromptSheet
252+ visible={!!asking}
253+ title={asking?.label ?? ""}
254+ label={asking?.input?.label ?? ""}
255+ placeholder={asking?.input?.placeholder ?? undefined}
256+ initial={asking?.input?.initial ?? ""}
257+ numeric={asking?.input?.kind === "money"}
258+ confirm={asking?.label ?? "Done"}
259+ onCancel={() => setAsking(null)}
260+ onDone={(value) => {
261+ const action = asking;
262+ setAsking(null);
263+ if (action) void run(action, value);
264+ }}
265+ />
266+ </View>
267+ );
268+}
269+
270+/** Whether two messages run together: the same author, within five minutes, neither a card. */
271+export function runsOn(previous: ChatMessage | undefined, message: ChatMessage): boolean {
272+ if (!previous || previous.kind === "card" || message.kind === "card") return false;
273+ if (previous.author.id !== message.author.id || previous.author.kind !== message.author.kind) return false;
274+ return Date.parse(message.created_at) - Date.parse(previous.created_at) < 5 * 60_000;
275+}
276+
277+const styles = StyleSheet.create({
278+ row: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingBottom: 2 },
279+ gutter: { width: 36 },
280+ header: { flexDirection: "row", alignItems: "center", gap: space.sm },
281+ reactions: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 2 },
282+ reaction: { flexDirection: "row", alignItems: "center", gap: 4, borderWidth: 1, borderRadius: radius.full, paddingHorizontal: 8, paddingVertical: 3 },
283+ replies: { flexDirection: "row", alignItems: "center", gap: 6, marginTop: 2 },
284+ status: { flexDirection: "row", alignItems: "center", gap: 4 },
285+ card: { borderWidth: 1, borderRadius: radius.md, padding: space.md, gap: space.sm, marginTop: 2 },
286+ fields: { flexDirection: "row", flexWrap: "wrap", gap: space.sm, justifyContent: "space-between" },
287+ actions: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
288+ action: { minHeight: 36, minWidth: 72, paddingHorizontal: space.md, borderRadius: radius.sm, borderWidth: 1, alignItems: "center", justifyContent: "center" },
289+});
+104−0
1+import type { LucideIcon } from "lucide-react-native";
2+import { type ReactNode, useEffect, useState } from "react";
3+import { KeyboardAvoidingView, Modal, Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
4+import { useSafeAreaInsets } from "react-native-safe-area-context";
5+
6+import { Button, Text } from "@/components/ui";
7+import { radius, space, useColors } from "@/theme";
8+
9+/** A panel from the bottom over a dimmed screen, closed by tapping outside it. */
10+export function Sheet({ visible, onClose, children }: { visible: boolean; onClose: () => void; children: ReactNode }) {
11+ const colors = useColors();
12+ const insets = useSafeAreaInsets();
13+ return (
14+ <Modal visible={visible} transparent animationType="slide" onRequestClose={onClose} statusBarTranslucent>
15+ <Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel="Close" />
16+ <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : undefined}>
17+ <View style={[styles.sheet, { backgroundColor: colors.surface, borderColor: colors.line, paddingBottom: insets.bottom + space.md }]}>
18+ <View style={[styles.grabber, { backgroundColor: colors.lineStrong }]} />
19+ {children}
20+ </View>
21+ </KeyboardAvoidingView>
22+ </Modal>
23+ );
24+}
25+
26+/** A row in a sheet: an icon and what it does. */
27+export function SheetAction({ icon: Icon, label, danger, onPress }: { icon: LucideIcon; label: string; danger?: boolean; onPress: () => void }) {
28+ const colors = useColors();
29+ return (
30+ <Pressable onPress={onPress} style={({ pressed }) => [styles.action, { backgroundColor: pressed ? colors.raised : "transparent" }]}>
31+ <Icon size={20} color={danger ? colors.danger : colors.fg} />
32+ <Text style={{ color: danger ? colors.danger : colors.fg }}>{label}</Text>
33+ </Pressable>
34+ );
35+}
36+
37+/** Asks for one value in a sheet: a card's amount, a new message text. */
38+export function PromptSheet({
39+ visible,
40+ title,
41+ label,
42+ placeholder,
43+ initial = "",
44+ numeric,
45+ multiline,
46+ confirm,
47+ onCancel,
48+ onDone,
49+}: {
50+ visible: boolean;
51+ title: string;
52+ label?: string;
53+ placeholder?: string;
54+ initial?: string;
55+ numeric?: boolean;
56+ multiline?: boolean;
57+ confirm: string;
58+ onCancel: () => void;
59+ onDone: (value: string) => void;
60+}) {
61+ const colors = useColors();
62+ const [value, setValue] = useState(initial);
63+ useEffect(() => {
64+ if (visible) setValue(initial);
65+ }, [visible, initial]);
66+ return (
67+ <Sheet visible={visible} onClose={onCancel}>
68+ <View style={{ paddingHorizontal: space.lg, gap: space.md }}>
69+ <Text size="heading">{title}</Text>
70+ {label ? (
71+ <Text size="small" tone="muted">
72+ {label}
73+ </Text>
74+ ) : null}
75+ <TextInput
76+ autoFocus
77+ value={value}
78+ onChangeText={setValue}
79+ placeholder={placeholder}
80+ placeholderTextColor={colors.faint}
81+ keyboardType={numeric ? "decimal-pad" : "default"}
82+ multiline={multiline}
83+ style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong, minHeight: multiline ? 96 : 46 }]}
84+ />
85+ <View style={{ flexDirection: "row", gap: space.sm }}>
86+ <View style={{ flex: 1 }}>
87+ <Button title="Cancel" variant="secondary" onPress={onCancel} />
88+ </View>
89+ <View style={{ flex: 1 }}>
90+ <Button title={confirm} disabled={!value.trim()} onPress={() => onDone(value.trim())} />
91+ </View>
92+ </View>
93+ </View>
94+ </Sheet>
95+ );
96+}
97+
98+const styles = StyleSheet.create({
99+ backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.45)" },
100+ sheet: { borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg, borderWidth: StyleSheet.hairlineWidth, paddingTop: space.sm, gap: space.sm },
101+ grabber: { alignSelf: "center", width: 36, height: 4, borderRadius: 2, marginBottom: space.xs },
102+ action: { flexDirection: "row", alignItems: "center", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md },
103+ input: { borderWidth: 1, borderRadius: radius.md, paddingHorizontal: space.md, paddingVertical: space.sm, fontSize: 15, textAlignVertical: "top" },
104+});
+150−0
1+import { CircleCheck, CircleDot, CircleX, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Loader } from "lucide-react-native";
2+import { ScrollView, StyleSheet, View } from "react-native";
3+
4+import { Badge, Text } from "@/components/ui";
5+import type { ApiChanges, ApiComment, ApiIssue, ApiPull, CheckStatus } from "@/lib/code";
6+import { ago } from "@/lib/time";
7+import { fonts, radius, space, useColors } from "@/theme";
8+
9+/** A pull request's state, as an icon in its colour. */
10+export function PullIcon({ pull }: { pull: Pick<ApiPull, "status"> }) {
11+ const colors = useColors();
12+ if (pull.status === "merged") return <GitMerge size={20} color={colors.merged} />;
13+ if (pull.status === "closed") return <GitPullRequestClosed size={20} color={colors.danger} />;
14+ if (pull.status === "draft") return <GitPullRequestDraft size={20} color={colors.muted} />;
15+ return <GitPullRequest size={20} color={colors.success} />;
16+}
17+
18+export function IssueIcon({ issue }: { issue: Pick<ApiIssue, "state" | "reason"> }) {
19+ const colors = useColors();
20+ if (issue.state === "open") return <CircleDot size={20} color={colors.success} />;
21+ return issue.reason === "not_planned" ? <CircleX size={20} color={colors.muted} /> : <CircleCheck size={20} color={colors.merged} />;
22+}
23+
24+export function Checks({ status }: { status: CheckStatus }) {
25+ const colors = useColors();
26+ if (status === "passed") return <CircleCheck size={16} color={colors.success} accessibilityLabel="Checks passed" />;
27+ if (status === "failed" || status === "errored") return <CircleX size={16} color={colors.danger} accessibilityLabel="Checks failed" />;
28+ if (status === "queued" || status === "running") return <Loader size={16} color={colors.warn} accessibilityLabel="Checks running" />;
29+ return null;
30+}
31+
32+export function StateBadge({ pull }: { pull: Pick<ApiPull, "status"> }) {
33+ const tone = pull.status === "merged" ? "merged" : pull.status === "closed" ? "danger" : pull.status === "open" ? "success" : "neutral";
34+ return <Badge label={pull.status[0].toUpperCase() + pull.status.slice(1)} tone={tone} />;
35+}
36+
37+/** Markdown, shown as it was written until the app renders it. */
38+export function Body({ text }: { text: string | null }) {
39+ const colors = useColors();
40+ if (!text?.trim()) {
41+ return (
42+ <Text tone="faint" style={{ fontStyle: "italic" }}>
43+ No description.
44+ </Text>
45+ );
46+ }
47+ return (
48+ <View style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface }]}>
49+ <Text selectable>{text}</Text>
50+ </View>
51+ );
52+}
53+
54+/** The conversation under an issue or pull request: comments, reviews and what happened. */
55+export function Timeline({ comments }: { comments: ApiComment[] }) {
56+ const colors = useColors();
57+ if (comments.length === 0) return null;
58+ return (
59+ <View style={{ gap: space.md }}>
60+ {comments.map((comment) =>
61+ comment.kind === "event" ? (
62+ <Text key={comment.id} size="small" tone="muted">
63+ {comment.author.display_username ?? comment.author.username} {comment.body} · {ago(comment.created_at)}
64+ </Text>
65+ ) : (
66+ <View key={comment.id} style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface, gap: space.xs }]}>
67+ <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}>
68+ <Text style={{ fontWeight: "600" }}>{comment.author.display_username ?? comment.author.username}</Text>
69+ {comment.author.kind === "agent" ? <Badge label="Agent" tone="accent" /> : null}
70+ {comment.verdict ? <Badge label={comment.verdict.replace(/_/g, " ")} tone={comment.verdict === "approve" ? "success" : "warn"} /> : null}
71+ <Text size="small" tone="faint">
72+ {ago(comment.created_at)}
73+ </Text>
74+ </View>
75+ {comment.path ? (
76+ <Text size="mono" tone="muted">
77+ {comment.path}
78+ {comment.line ? `:${comment.line}` : ""}
79+ </Text>
80+ ) : null}
81+ <Text selectable>{comment.body}</Text>
82+ </View>
83+ ),
84+ )}
85+ </View>
86+ );
87+}
88+
89+/** What a pull request changes, file by file, as unified hunks. */
90+export function Diff({ changes }: { changes: ApiChanges }) {
91+ const colors = useColors();
92+ return (
93+ <View style={{ gap: space.lg }}>
94+ {changes.files.map((file) => (
95+ <View key={file.path} style={[styles.file, { borderColor: colors.line }]}>
96+ <View style={[styles.fileHeader, { backgroundColor: colors.raised, borderBottomColor: colors.line }]}>
97+ <Text size="mono" style={{ flex: 1 }} numberOfLines={1}>
98+ {file.path}
99+ </Text>
100+ <Text size="mono" tone="success">
101+ +{file.additions}
102+ </Text>
103+ <Text size="mono" tone="danger">
104+ −{file.deletions}
105+ </Text>
106+ </View>
107+ {file.binary ? (
108+ <Text tone="muted" size="small" style={{ padding: space.md }}>
109+ A binary file.
110+ </Text>
111+ ) : (
112+ <ScrollView horizontal>
113+ <View>
114+ {(file.hunks ?? []).flatMap((hunk, index) =>
115+ hunk.lines.map((line, at) => (
116+ <Text
117+ key={`${index}-${at}`}
118+ style={{
119+ fontFamily: fonts.mono,
120+ fontSize: 12,
121+ lineHeight: 18,
122+ paddingHorizontal: space.sm,
123+ color: line.kind === "add" ? colors.success : line.kind === "delete" ? colors.danger : colors.fgSoft,
124+ backgroundColor: line.kind === "add" ? colors.successLow : "transparent",
125+ }}
126+ >
127+ {line.kind === "add" ? "+ " : line.kind === "delete" ? "− " : " "}
128+ {line.text}
129+ </Text>
130+ )),
131+ )}
132+ </View>
133+ </ScrollView>
134+ )}
135+ </View>
136+ ))}
137+ {changes.truncated ? (
138+ <Text tone="muted" size="small">
139+ Too much changed to show here. Open it on g1t for the rest.
140+ </Text>
141+ ) : null}
142+ </View>
143+ );
144+}
145+
146+const styles = StyleSheet.create({
147+ box: { borderWidth: 1, borderRadius: radius.md, padding: space.md },
148+ file: { borderWidth: 1, borderRadius: radius.md, overflow: "hidden" },
149+ fileHeader: { flexDirection: "row", gap: space.sm, padding: space.sm, borderBottomWidth: StyleSheet.hairlineWidth },
150+});
+34−0
1+import { Stack } from "expo-router";
2+import * as WebBrowser from "expo-web-browser";
3+import type { LucideIcon } from "lucide-react-native";
4+import { ExternalLink } from "lucide-react-native";
5+
6+import { Button, Empty, Screen } from "@/components/ui";
7+import { siteUrl } from "@/lib/server";
8+import { useSignedIn } from "@/lib/session";
9+
10+/**
11+ * A part of g1t the app has no screens for yet: what it is, and the way
12+ * to it on the site meanwhile.
13+ */
14+export function ComingSoon({ title, icon, body, path }: { title: string; icon: LucideIcon; body: string; path: (workspace: string) => string }) {
15+ const { server, workspace } = useSignedIn();
16+ return (
17+ <Screen>
18+ <Stack.Screen options={{ title }} />
19+ <Empty
20+ icon={icon}
21+ title={`${title} is coming to the app`}
22+ body={body}
23+ action={
24+ <Button
25+ title="Open on g1t"
26+ variant="secondary"
27+ icon={ExternalLink}
28+ onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, path(workspace?.slug ?? "")))}
29+ />
30+ }
31+ />
32+ </Screen>
33+ );
34+}
+34−0
1+import Svg, { Rect } from "react-native-svg";
2+
3+import { useColors } from "@/theme";
4+
5+/**
6+ * The g1t mark, as packages/theme/mark.svg draws it: pixels on an 80 by
7+ * 80 grid, in the accent.
8+ */
9+const PIXELS: [number, number][] = [
10+ [36, 6],
11+ [26, 16],
12+ [36, 16],
13+ [16, 26],
14+ [36, 26],
15+ [36, 36],
16+ [36, 46],
17+ [36, 56],
18+ [16, 66],
19+ [26, 66],
20+ [36, 66],
21+ [46, 66],
22+ [56, 66],
23+];
24+
25+export function G1tMark({ size = 32 }: { size?: number }) {
26+ const colors = useColors();
27+ return (
28+ <Svg width={size} height={size} viewBox="0 0 80 80" accessibilityLabel="g1t">
29+ {PIXELS.map(([x, y]) => (
30+ <Rect key={`${x}-${y}`} x={x} y={y} width={8} height={8} fill={colors.accent} />
31+ ))}
32+ </Svg>
33+ );
34+}
+34−0
1+import { Pressable, StyleSheet, View } from "react-native";
2+
3+import { Text } from "@/components/ui";
4+import { radius, space, useColors } from "@/theme";
5+
6+/** A row of choices, one picked: the site's tabs, on a phone. */
7+export function Segments<T extends string>({ options, value, onChange }: { options: { value: T; label: string }[]; value: T; onChange: (value: T) => void }) {
8+ const colors = useColors();
9+ return (
10+ <View style={[styles.row, { backgroundColor: colors.raised }]}>
11+ {options.map((option) => {
12+ const on = option.value === value;
13+ return (
14+ <Pressable
15+ key={option.value}
16+ accessibilityRole="tab"
17+ accessibilityState={{ selected: on }}
18+ onPress={() => onChange(option.value)}
19+ style={[styles.option, on && { backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
20+ >
21+ <Text size="small" style={{ fontWeight: "600", color: on ? colors.fg : colors.muted }}>
22+ {option.label}
23+ </Text>
24+ </Pressable>
25+ );
26+ })}
27+ </View>
28+ );
29+}
30+
31+const styles = StyleSheet.create({
32+ row: { flexDirection: "row", borderRadius: radius.md, padding: 3, gap: 3 },
33+ option: { flex: 1, alignItems: "center", paddingVertical: space.sm, borderRadius: radius.sm, borderWidth: 1, borderColor: "transparent" },
34+});
+19−0
1+import { Stack } from "expo-router";
2+
3+import { useColors } from "@/theme";
4+
5+/** A tab's stack of screens, with g1t's header. */
6+export function TabStack() {
7+ const colors = useColors();
8+ return (
9+ <Stack
10+ screenOptions={{
11+ headerStyle: { backgroundColor: colors.surface },
12+ headerTintColor: colors.fg,
13+ headerTitleStyle: { color: colors.fg },
14+ headerShadowVisible: false,
15+ contentStyle: { backgroundColor: colors.bg },
16+ }}
17+ />
18+ );
19+}
+269−0
1+import { Image } from "expo-image";
2+import type { LucideIcon } from "lucide-react-native";
3+import { ChevronRight } from "lucide-react-native";
4+import type { ReactNode } from "react";
5+import {
6+ ActivityIndicator,
7+ Pressable,
8+ type PressableProps,
9+ ScrollView,
10+ StyleSheet,
11+ Text as NativeText,
12+ type TextProps,
13+ TextInput,
14+ type TextInputProps,
15+ View,
16+ type ViewProps,
17+} from "react-native";
18+import { SafeAreaView } from "react-native-safe-area-context";
19+
20+import { ApiError } from "@/lib/client";
21+import { fonts, radius, space, useColors } from "@/theme";
22+
23+/**
24+ * The app's building blocks, themed with g1t's tokens. As on the site
25+ * (apps/web/app/components/ui), a screen is built from these, and a look
26+ * changes through a component's variant, not a style at the call site.
27+ */
28+
29+type Tone = "default" | "muted" | "faint" | "accent" | "danger" | "success";
30+type Size = "title" | "heading" | "body" | "small" | "mono";
31+
32+const SIZES: Record<Size, { fontSize: number; lineHeight: number; fontWeight?: "600" | "700" }> = {
33+ title: { fontSize: 26, lineHeight: 32, fontWeight: "700" },
34+ heading: { fontSize: 17, lineHeight: 22, fontWeight: "600" },
35+ body: { fontSize: 15, lineHeight: 21 },
36+ small: { fontSize: 13, lineHeight: 18 },
37+ mono: { fontSize: 12, lineHeight: 17 },
38+};
39+
40+export function Text({ size = "body", tone = "default", style, ...props }: TextProps & { size?: Size; tone?: Tone }) {
41+ const colors = useColors();
42+ const color = { default: colors.fg, muted: colors.muted, faint: colors.faint, accent: colors.accent, danger: colors.danger, success: colors.success }[tone];
43+ return <NativeText style={[SIZES[size], size === "mono" && { fontFamily: fonts.mono }, { color }, style]} {...props} />;
44+}
45+
46+/** A screen's background, inside the safe area when it has no header above it. */
47+export function Screen({ children, scroll, safe, style, ...props }: ViewProps & { scroll?: boolean; safe?: boolean }) {
48+ const colors = useColors();
49+ const body = scroll ? (
50+ <ScrollView contentContainerStyle={[styles.padded, style]} keyboardShouldPersistTaps="handled">
51+ {children}
52+ </ScrollView>
53+ ) : (
54+ <View style={[{ flex: 1 }, style]} {...props}>
55+ {children}
56+ </View>
57+ );
58+ return safe ? (
59+ <SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }}>{body}</SafeAreaView>
60+ ) : (
61+ <View style={{ flex: 1, backgroundColor: colors.bg }}>{body}</View>
62+ );
63+}
64+
65+export function Button({
66+ title,
67+ variant = "primary",
68+ busy,
69+ disabled,
70+ icon: Icon,
71+ ...props
72+}: PressableProps & { title: string; variant?: "primary" | "secondary" | "danger"; busy?: boolean; icon?: LucideIcon }) {
73+ const colors = useColors();
74+ const look = {
75+ primary: { bg: colors.accent, fg: colors.onAccent, border: colors.accent },
76+ secondary: { bg: colors.surface, fg: colors.fg, border: colors.lineStrong },
77+ danger: { bg: colors.surface, fg: colors.danger, border: colors.lineStrong },
78+ }[variant];
79+ const off = disabled || busy;
80+ return (
81+ <Pressable
82+ accessibilityRole="button"
83+ disabled={off}
84+ style={({ pressed }) => [styles.button, { backgroundColor: look.bg, borderColor: look.border, opacity: off ? 0.55 : pressed ? 0.8 : 1 }]}
85+ {...props}
86+ >
87+ {busy ? <ActivityIndicator color={look.fg} /> : Icon ? <Icon size={18} color={look.fg} /> : null}
88+ <Text style={{ color: look.fg, fontWeight: "600" }}>{title}</Text>
89+ </Pressable>
90+ );
91+}
92+
93+export function Field({ label, hint, ...props }: TextInputProps & { label: string; hint?: string }) {
94+ const colors = useColors();
95+ return (
96+ <View style={{ gap: space.xs }}>
97+ <Text size="small" tone="muted">
98+ {label}
99+ </Text>
100+ <TextInput
101+ placeholderTextColor={colors.faint}
102+ autoCapitalize="none"
103+ autoCorrect={false}
104+ style={[styles.input, { color: colors.fg, backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
105+ {...props}
106+ />
107+ {hint ? (
108+ <Text size="small" tone="faint">
109+ {hint}
110+ </Text>
111+ ) : null}
112+ </View>
113+ );
114+}
115+
116+/** A row in a list: what it is, a line under it, and what is at its end. */
117+export function Row({
118+ title,
119+ subtitle,
120+ leading,
121+ trailing,
122+ chevron = true,
123+ onPress,
124+}: {
125+ title: ReactNode;
126+ subtitle?: ReactNode;
127+ leading?: ReactNode;
128+ trailing?: ReactNode;
129+ chevron?: boolean;
130+ onPress?: () => void;
131+}) {
132+ const colors = useColors();
133+ return (
134+ <Pressable
135+ onPress={onPress}
136+ disabled={!onPress}
137+ style={({ pressed }) => [styles.row, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]}
138+ >
139+ {leading}
140+ <View style={{ flex: 1, gap: 2 }}>
141+ {typeof title === "string" ? (
142+ <Text numberOfLines={1} style={{ fontWeight: "600" }}>
143+ {title}
144+ </Text>
145+ ) : (
146+ title
147+ )}
148+ {typeof subtitle === "string" ? (
149+ <Text size="small" tone="muted" numberOfLines={2}>
150+ {subtitle}
151+ </Text>
152+ ) : (
153+ subtitle
154+ )}
155+ </View>
156+ {trailing}
157+ {onPress && chevron ? <ChevronRight size={18} color={colors.faint} /> : null}
158+ </Pressable>
159+ );
160+}
161+
162+export function SectionHeader({ title }: { title: string }) {
163+ const colors = useColors();
164+ return (
165+ <View style={[styles.sectionHeader, { backgroundColor: colors.bg }]}>
166+ <Text size="small" tone="muted" style={{ fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.6 }}>
167+ {title}
168+ </Text>
169+ </View>
170+ );
171+}
172+
173+type BadgeTone = "neutral" | "accent" | "success" | "danger" | "merged" | "warn";
174+
175+export function Badge({ label, tone = "neutral" }: { label: string; tone?: BadgeTone }) {
176+ const colors = useColors();
177+ const fg = { neutral: colors.muted, accent: colors.accent, success: colors.success, danger: colors.danger, merged: colors.merged, warn: colors.warn }[tone];
178+ return (
179+ <View style={[styles.badge, { borderColor: fg }]}>
180+ <Text size="small" style={{ color: fg, fontWeight: "600" }}>
181+ {label}
182+ </Text>
183+ </View>
184+ );
185+}
186+
187+/**
188+ * A person's or an agent's picture: the uploaded one when there is one,
189+ * else their initial. Agents carry a marker, as everywhere on g1t. The
190+ * site draws an agent's own bot face (components/agent-face.tsx); bringing
191+ * that here is to come.
192+ */
193+export function Avatar({ name, uri, agent, size = 32 }: { name: string; uri?: string | null; agent?: boolean; size?: number }) {
194+ const colors = useColors();
195+ const corner = agent ? radius.sm : size / 2;
196+ return (
197+ <View accessibilityLabel={agent ? `${name}, agent` : name} style={{ width: size, height: size }}>
198+ {uri ? (
199+ <Image source={{ uri }} style={{ width: size, height: size, borderRadius: corner }} />
200+ ) : (
201+ <View style={{ width: size, height: size, borderRadius: corner, backgroundColor: colors.accentLow, alignItems: "center", justifyContent: "center" }}>
202+ <Text style={{ color: colors.accent, fontWeight: "700", fontSize: size * 0.42, lineHeight: size * 0.5 }}>{name.slice(0, 1).toUpperCase()}</Text>
203+ </View>
204+ )}
205+ {agent ? <View style={[styles.agentMarker, { backgroundColor: colors.accent, borderColor: colors.bg }]} /> : null}
206+ </View>
207+ );
208+}
209+
210+export function Loading() {
211+ const colors = useColors();
212+ return (
213+ <View style={styles.center}>
214+ <ActivityIndicator color={colors.accent} />
215+ </View>
216+ );
217+}
218+
219+export function Empty({ icon: Icon, title, body, action }: { icon?: LucideIcon; title: string; body?: string; action?: ReactNode }) {
220+ const colors = useColors();
221+ return (
222+ <View style={[styles.center, { gap: space.sm, padding: space.xl }]}>
223+ {Icon ? <Icon size={28} color={colors.faint} /> : null}
224+ <Text size="heading" style={{ textAlign: "center" }}>
225+ {title}
226+ </Text>
227+ {body ? (
228+ <Text tone="muted" style={{ textAlign: "center" }}>
229+ {body}
230+ </Text>
231+ ) : null}
232+ {action}
233+ </View>
234+ );
235+}
236+
237+/** What went wrong, in words, with a way to try again. */
238+export function ErrorView({ error, retry }: { error: unknown; retry?: () => void }) {
239+ const message = error instanceof Error ? error.message : "Something went wrong.";
240+ const title = error instanceof ApiError && error.code === "website_refused" ? "Chat needs another sign-in" : "Could not load this";
241+ return <Empty title={title} body={message} action={retry ? <Button title="Try again" variant="secondary" onPress={retry} /> : null} />;
242+}
243+
244+const styles = StyleSheet.create({
245+ padded: { padding: space.lg, gap: space.lg },
246+ button: {
247+ minHeight: 46,
248+ borderRadius: radius.md,
249+ borderWidth: 1,
250+ paddingHorizontal: space.lg,
251+ flexDirection: "row",
252+ alignItems: "center",
253+ justifyContent: "center",
254+ gap: space.sm,
255+ },
256+ input: { minHeight: 46, borderRadius: radius.md, borderWidth: 1, paddingHorizontal: space.md, fontSize: 15 },
257+ row: {
258+ flexDirection: "row",
259+ alignItems: "center",
260+ gap: space.md,
261+ paddingHorizontal: space.lg,
262+ paddingVertical: space.md,
263+ borderBottomWidth: StyleSheet.hairlineWidth,
264+ },
265+ sectionHeader: { paddingHorizontal: space.lg, paddingTop: space.lg, paddingBottom: space.sm },
266+ badge: { borderWidth: 1, borderRadius: radius.full, paddingHorizontal: space.sm, paddingVertical: 1 },
267+ agentMarker: { position: "absolute", right: -2, bottom: -2, width: 10, height: 10, borderRadius: 3, borderWidth: 2 },
268+ center: { flex: 1, alignItems: "center", justifyContent: "center" },
269+});
+367−0
1+import type { Channel, ChatLiveEvent, ChatMessage, ChatSidebar, MemberProfile, MessagePage } from "@g1t/contracts";
2+import { type InfiniteData, type QueryClient, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3+import { useCallback, useEffect, useRef, useState } from "react";
4+
5+import type { Client } from "./client";
6+import { openSocket } from "./live";
7+import { useSignedIn } from "./session";
8+
9+/**
10+ * Chat, through the site's routes, as the site's own Chat pages use them
11+ * (apps/web/app/routes/workspace/chat/api.ts):
12+ *
13+ * - `GET /<workspace>/-/chat/api`: the sidebar.
14+ * - `GET /<workspace>/-/chat/api?channel=<id>[&thread=<id>]&before=<id>`: a page of messages.
15+ * - `POST /<workspace>/-/chat/api` with `{ intent, … }`: everything sent.
16+ * - `wss://<site>/<workspace>/-/chat/live?channel=<id>`: what happens in a
17+ * conversation, live, and the one thing a client says there: typing.
18+ *
19+ * The app signs in with `chat:read` and `chat:write`, which the site takes
20+ * on these routes alone.
21+ */
22+
23+const chatPath = (workspace: string) => `/${encodeURIComponent(workspace)}/-/chat/api`;
24+
25+export const chatKeys = {
26+ all: (workspace: string) => ["chat", workspace] as const,
27+ sidebar: (workspace: string) => ["chat", workspace, "sidebar"] as const,
28+ browse: (workspace: string) => ["chat", workspace, "browse"] as const,
29+ messages: (workspace: string, channel: string, thread: string | null) => ["chat", workspace, "messages", channel, thread] as const,
30+};
31+
32+/** A message as the app keeps it: a sent one may still be on its way, or have failed. */
33+export type Shown = ChatMessage & { local?: "sending" | "failed" };
34+
35+type Pages = InfiniteData<MessagePage & { messages: Shown[] }, string | null>;
36+
37+/** Posts an intent to the chat route and returns its value. */
38+export function sendIntent<T>(client: Client, workspace: string, intent: string, body: Record<string, unknown>): Promise<T> {
39+ return client.site<T>(chatPath(workspace), { method: "POST", body: JSON.stringify({ intent, ...body }) });
40+}
41+
42+// ── Reading ────────────────────────────────────────────────────────────────
43+
44+export function useChatSidebar() {
45+ const { client, workspace } = useSignedIn();
46+ const slug = workspace?.slug ?? "";
47+ return useQuery({
48+ queryKey: chatKeys.sidebar(slug),
49+ queryFn: () => client.site<ChatSidebar>(chatPath(slug)),
50+ enabled: !!slug,
51+ });
52+}
53+
54+/** The workspace's channels, to find and join (`GET …/chat/api?browse=1`). */
55+export function useBrowseChannels() {
56+ const { client, workspace } = useSignedIn();
57+ const slug = workspace?.slug ?? "";
58+ return useQuery({
59+ queryKey: chatKeys.browse(slug),
60+ queryFn: () => client.site<Channel[]>(`${chatPath(slug)}?browse=1`),
61+ enabled: !!slug,
62+ });
63+}
64+
65+export function useMessages(channelId: string, thread: string | null = null) {
66+ const { client, workspace } = useSignedIn();
67+ const slug = workspace?.slug ?? "";
68+ return useInfiniteQuery({
69+ queryKey: chatKeys.messages(slug, channelId, thread),
70+ initialPageParam: null as string | null,
71+ queryFn: ({ pageParam }) => {
72+ const query = new URLSearchParams({ channel: channelId, limit: "50" });
73+ if (thread) query.set("thread", thread);
74+ if (pageParam) query.set("before", pageParam);
75+ return client.site<MessagePage>(`${chatPath(slug)}?${query}`);
76+ },
77+ getNextPageParam: (page) => page.older,
78+ enabled: !!slug && !!channelId,
79+ });
80+}
81+
82+// ── The cache, as messages arrive ───────────────────────────────────────────
83+
84+function updatePages(queries: QueryClient, key: readonly unknown[], change: (messages: Shown[], index: number) => Shown[]) {
85+ queries.setQueryData<Pages>(key, (data) => (data ? { ...data, pages: data.pages.map((page, index) => ({ ...page, messages: change(page.messages, index) })) } : data));
86+}
87+
88+/** A message, put where it belongs: replacing itself, or its local copy, or added as the newest. */
89+export function upsertMessage(queries: QueryClient, workspace: string, message: Shown, localId?: string) {
90+ const key = chatKeys.messages(workspace, message.channel_id, message.thread_root ?? null);
91+ const data = queries.getQueryData<Pages>(key);
92+ const had = !!data?.pages.some((page) => page.messages.some((one) => one.id === message.id));
93+ const hadLocal = !!localId && !!data?.pages.some((page) => page.messages.some((one) => one.id === localId));
94+ if (data) {
95+ if (had || hadLocal) {
96+ updatePages(queries, key, (messages) => {
97+ const replaced = messages.map((one) => (one.id === message.id || (localId && one.id === localId) ? message : one));
98+ // A local copy and the real one may both have arrived: keep one.
99+ return replaced.filter((one, index) => replaced.findIndex((other) => other.id === one.id) === index);
100+ });
101+ } else {
102+ updatePages(queries, key, (messages, index) => (index === 0 ? [...messages, message] : messages));
103+ }
104+ }
105+ // A reply new to the app also counts on its root, in the conversation.
106+ if (message.thread_root && !message.local && !had) {
107+ updatePages(queries, chatKeys.messages(workspace, message.channel_id, null), (messages) =>
108+ messages.map((one) => (one.id === message.thread_root ? { ...one, reply_count: one.reply_count + 1, last_reply_at: message.created_at } : one)),
109+ );
110+ }
111+}
112+
113+function removeMessage(queries: QueryClient, workspace: string, channelId: string, id: string) {
114+ for (const [key] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", channelId] })) {
115+ updatePages(queries, key, (messages) => messages.filter((one) => one.id !== id));
116+ }
117+}
118+
119+function patchMessage(queries: QueryClient, workspace: string, channelId: string, id: string, change: (message: Shown) => Shown) {
120+ for (const [key] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", channelId] })) {
121+ updatePages(queries, key, (messages) => messages.map((one) => (one.id === id ? change(one) : one)));
122+ }
123+}
124+
125+/** A reaction added or taken away, by the viewer (`mine`) or someone else, on a message in the cache. */
126+function applyReaction(queries: QueryClient, workspace: string, channelId: string, messageId: string, emoji: string, member: MemberProfile | null, added: boolean, mine: boolean) {
127+ patchMessage(queries, workspace, channelId, messageId, (message) => {
128+ const reactions = [...(message.reactions ?? [])];
129+ const at = reactions.findIndex((one) => one.emoji === emoji);
130+ if (at === -1) {
131+ if (!added) return message;
132+ reactions.push({ emoji, count: 1, me: mine, by: member ? [member] : [] });
133+ } else {
134+ const one = reactions[at];
135+ if (mine && one.me === added) return message;
136+ const count = one.count + (added ? 1 : -1);
137+ if (count <= 0) reactions.splice(at, 1);
138+ else
139+ reactions[at] = {
140+ ...one,
141+ count,
142+ me: mine ? added : one.me,
143+ by: member ? (added ? [...one.by, member] : one.by.filter((m) => m.id !== member.id)) : one.by,
144+ };
145+ }
146+ return { ...message, reactions };
147+ });
148+}
149+
150+/**
151+ * Live events carry every reaction's `me` as false, since the whole room
152+ * gets them: keep the viewer's own from the copy the app has.
153+ */
154+function keepMine(queries: QueryClient, workspace: string, message: ChatMessage): ChatMessage {
155+ for (const [, data] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", message.channel_id] })) {
156+ const had = data?.pages.flatMap((page) => page.messages).find((one) => one.id === message.id);
157+ if (had) {
158+ const mine = new Set((had.reactions ?? []).filter((one) => one.me).map((one) => one.emoji));
159+ return { ...message, reactions: (message.reactions ?? []).map((one) => ({ ...one, me: mine.has(one.emoji) })) };
160+ }
161+ }
162+ return message;
163+}
164+
165+// ── Sending ────────────────────────────────────────────────────────────────
166+
167+let localCounter = 0;
168+
169+/**
170+ * Sends a message. It shows at once, marked as sending, and becomes the
171+ * real one when the site answers (or when the live socket brings it,
172+ * whichever is first); a failed one stays, marked, to send again.
173+ */
174+export function useSend(channelId: string, thread: string | null = null) {
175+ const { client, workspace, me } = useSignedIn();
176+ const queries = useQueryClient();
177+ const slug = workspace?.slug ?? "";
178+
179+ return useCallback(
180+ (body: string, retryOf?: Shown) => {
181+ const localId = retryOf?.id ?? `local-${Date.now()}-${localCounter++}`;
182+ const local: Shown = {
183+ id: localId,
184+ channel_id: channelId,
185+ author: {
186+ kind: "user",
187+ id: me.id,
188+ name: me.username,
189+ display_username: me.display_username ?? null,
190+ display_name: me.display_username ?? me.username,
191+ avatar: me.avatar ?? null,
192+ role: null,
193+ } as MemberProfile,
194+ kind: "text",
195+ body,
196+ card: null,
197+ thread_root: thread,
198+ reply_count: 0,
199+ last_reply_at: null,
200+ created_at: new Date().toISOString(),
201+ edited_at: null,
202+ deleted_at: null,
203+ reactions: [],
204+ local: "sending",
205+ };
206+ upsertMessage(queries, slug, local, localId);
207+ sendIntent<ChatMessage>(client, slug, "post", { channel_id: channelId, body, thread_root: thread })
208+ .then((message) => {
209+ upsertMessage(queries, slug, message, localId);
210+ void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
211+ })
212+ .catch(() => upsertMessage(queries, slug, { ...local, local: "failed" }, localId));
213+ },
214+ [client, queries, slug, channelId, thread, me],
215+ );
216+}
217+
218+/** Everything else a person does to messages and conversations. */
219+export function useChatActions(channelId: string) {
220+ const { client, workspace } = useSignedIn();
221+ const queries = useQueryClient();
222+ const slug = workspace?.slug ?? "";
223+
224+ const react = useMutation({
225+ mutationFn: ({ message, emoji, add }: { message: ChatMessage; emoji: string; add: boolean }) =>
226+ sendIntent(client, slug, add ? "react" : "unreact", { channel_id: channelId, message_id: message.id, emoji }),
227+ onMutate: ({ message, emoji, add }) => applyReaction(queries, slug, channelId, message.id, emoji, null, add, true),
228+ onError: (_error, { message, emoji, add }) => applyReaction(queries, slug, channelId, message.id, emoji, null, !add, true),
229+ });
230+
231+ const edit = useMutation({
232+ mutationFn: ({ message, body }: { message: ChatMessage; body: string }) => sendIntent<ChatMessage>(client, slug, "edit", { channel_id: channelId, id: message.id, body }),
233+ onSuccess: (message) => upsertMessage(queries, slug, message),
234+ });
235+
236+ const remove = useMutation({
237+ mutationFn: (message: ChatMessage) => sendIntent(client, slug, "remove", { channel_id: channelId, id: message.id }),
238+ onMutate: (message) => removeMessage(queries, slug, channelId, message.id),
239+ onError: () => queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }),
240+ });
241+
242+ /** Presses a button on an agent's card: approve, stop, file. */
243+ const cardAction = useMutation({
244+ mutationFn: ({ message, action, input }: { message: ChatMessage; action: string; input?: string | null }) =>
245+ sendIntent<{ ok: boolean; message: string | null }>(client, slug, "card_action", { channel_id: channelId, message_id: message.id, action_id: action, input: input ?? null }),
246+ });
247+
248+ const preferences = useMutation({
249+ mutationFn: (change: { starred?: boolean; muted?: boolean }) => sendIntent(client, slug, "preferences", { channel_id: channelId, ...change }),
250+ onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
251+ });
252+
253+ const leave = useMutation({
254+ mutationFn: () => sendIntent(client, slug, "leave", { channel_id: channelId }),
255+ onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
256+ });
257+
258+ return { react, edit, remove, cardAction, preferences, leave };
259+}
260+
261+/** Marks a conversation read up to a message; the sidebar's counts follow. */
262+export function markRead(client: Client, queries: QueryClient, workspace: string, channelId: string, messageId: string) {
263+ queries.setQueryData<ChatSidebar>(chatKeys.sidebar(workspace), (data) =>
264+ data ? { ...data, entries: data.entries.map((entry) => (entry.channel.id === channelId ? { ...entry, unread: 0, mentions: 0 } : entry)) } : data,
265+ );
266+ return sendIntent(client, workspace, "read", { channel_id: channelId, id: messageId }).catch(() => undefined);
267+}
268+
269+export function useStartConversation() {
270+ const { client, workspace } = useSignedIn();
271+ const queries = useQueryClient();
272+ const slug = workspace?.slug ?? "";
273+ return {
274+ /** Opens (or finds) a direct message with these members: `user:<username>` or `agent:<id>`. */
275+ dm: useMutation({
276+ mutationFn: (members: string[]) => sendIntent<{ channel: Channel }>(client, slug, "dm", { members }),
277+ onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
278+ }),
279+ join: useMutation({
280+ mutationFn: (channelId: string) => sendIntent(client, slug, "join", { channel_id: channelId }),
281+ onSuccess: () => {
282+ void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
283+ void queries.invalidateQueries({ queryKey: chatKeys.browse(slug) });
284+ },
285+ }),
286+ create: useMutation({
287+ mutationFn: (channel: { name: string; topic?: string; private?: boolean }) => sendIntent<{ channel: Channel }>(client, slug, "create_channel", channel),
288+ onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
289+ }),
290+ };
291+}
292+
293+// ── Live ───────────────────────────────────────────────────────────────────
294+
295+export type LiveStatus = "connecting" | "live" | "offline";
296+
297+/** Who is typing, until when. */
298+type Typing = { member: MemberProfile; until: number };
299+
300+/**
301+ * Keeps a conversation live while its screen is open: applies what its
302+ * socket says to the cache, says who is typing, and lets the composer say
303+ * the viewer is (at most every 3 s, as the room takes it).
304+ */
305+export function useChannelLive(channelId: string) {
306+ const { client, workspace, me } = useSignedIn();
307+ const queries = useQueryClient();
308+ const slug = workspace?.slug ?? "";
309+ const [status, setStatus] = useState<LiveStatus>("connecting");
310+ const [typing, setTyping] = useState<Typing[]>([]);
311+ const send = useRef<((frame: unknown) => void) | null>(null);
312+ const typedAt = useRef(0);
313+
314+ useEffect(() => {
315+ if (!slug || !channelId) return;
316+ const socket = openSocket(client, `/${encodeURIComponent(slug)}/-/chat/live`, { channel: channelId }, {
317+ onStatus: setStatus,
318+ onReconnect: () => void queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }),
319+ onMessage: (data) => {
320+ const live = data as ChatLiveEvent;
321+ switch (live.type) {
322+ case "message.created":
323+ case "message.updated":
324+ upsertMessage(queries, slug, keepMine(queries, slug, live.message));
325+ setTyping((now) => now.filter((one) => one.member.id !== live.message.author.id));
326+ break;
327+ case "message.deleted":
328+ removeMessage(queries, slug, live.channel_id, live.id);
329+ break;
330+ case "reaction.added":
331+ case "reaction.removed":
332+ if (live.member.id !== me.id) applyReaction(queries, slug, live.channel_id, live.message_id, live.emoji, live.member, live.type === "reaction.added", false);
333+ break;
334+ case "typing":
335+ if (live.member.id === me.id) break;
336+ setTyping((now) => [...now.filter((one) => one.member.id !== live.member.id), { member: live.member, until: Date.parse(live.until) }]);
337+ break;
338+ case "channel.updated":
339+ void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
340+ break;
341+ }
342+ },
343+ });
344+ send.current = socket.send;
345+ return () => {
346+ send.current = null;
347+ socket.close();
348+ };
349+ }, [client, queries, slug, channelId, me.id]);
350+
351+ // Typing lapses on its own.
352+ useEffect(() => {
353+ if (typing.length === 0) return;
354+ const next = Math.min(...typing.map((one) => one.until)) - Date.now();
355+ const timer = setTimeout(() => setTyping((now) => now.filter((one) => one.until > Date.now())), Math.max(250, next));
356+ return () => clearTimeout(timer);
357+ }, [typing]);
358+
359+ const sayTyping = useCallback(() => {
360+ const now = Date.now();
361+ if (now - typedAt.current < 3000) return;
362+ typedAt.current = now;
363+ send.current?.({ type: "typing", channel_id: channelId });
364+ }, [channelId]);
365+
366+ return { status, typing: typing.map((one) => one.member), sayTyping };
367+}
+185−0
1+import { type Credentials, saveCredentials } from "./credentials";
2+import { SignedOut, refresh } from "./oauth";
3+import type { Server } from "./server";
4+
5+/**
6+ * Every request the app makes, with its credentials: `api` for the REST
7+ * API (api.g1t.sh), `site` for the site's own JSON routes, which Chat
8+ * lives behind (`/<workspace>/-/chat/api`). Both take
9+ * `Authorization: Bearer g1t_…`.
10+ *
11+ * An OAuth access token is refreshed a minute before it expires, and on a
12+ * 401 once; requests that need a refresh at the same time share it,
13+ * because a refresh token works once.
14+ */
15+
16+export type ErrorCode =
17+ /** Signed out: the session is over and the app returns to sign-in. */
18+ | "signed_out"
19+ /**
20+ * The site refused the token: an OAuth token, or a personal one without
21+ * "Use the website as you". The API still works; Chat does not.
22+ */
23+ | "website_refused"
24+ | "not_found"
25+ | "forbidden"
26+ | "offline"
27+ | "failed";
28+
29+export class ApiError extends Error {
30+ constructor(
31+ readonly code: ErrorCode,
32+ message: string,
33+ readonly status = 0,
34+ ) {
35+ super(message);
36+ }
37+}
38+
39+export type Session = {
40+ server: Server;
41+ credentials: Credentials;
42+ /** Told when credentials change (a refresh) or end (signed out). */
43+ onCredentials: (next: Credentials | null) => void;
44+};
45+
46+const REFRESH_EARLY_MS = 60_000;
47+
48+export class Client {
49+ private refreshing: Promise<Credentials> | null = null;
50+
51+ constructor(private session: Session) {}
52+
53+ get server(): Server {
54+ return this.session.server;
55+ }
56+
57+ get credentials(): Credentials {
58+ return this.session.credentials;
59+ }
60+
61+ /** A REST API call: `client.api<Repo[]>("/repos")`. */
62+ api<T>(path: string, init?: RequestInit): Promise<T> {
63+ return this.json<T>(this.session.server.api + path, init, "api");
64+ }
65+
66+ /**
67+ * One of the site's JSON routes. They answer `{ ok, value }` or
68+ * `{ ok: false, error }`, the services' own shape, which this unwraps.
69+ */
70+ async site<T>(path: string, init?: RequestInit): Promise<T> {
71+ const answer = await this.json<{ ok: true; value: T } | { ok: false; error: { code: string; message: string } }>(
72+ this.session.server.site + path,
73+ init,
74+ "site",
75+ );
76+ if (!answer.ok) {
77+ const code: ErrorCode = answer.error.code === "not_found" ? "not_found" : answer.error.code === "forbidden" ? "forbidden" : "failed";
78+ throw new ApiError(code, answer.error.message);
79+ }
80+ return answer.value;
81+ }
82+
83+ /** One of the site's JSON routes that answers in its own shape, such as `/-/notify`. */
84+ siteRaw<T>(path: string, init?: RequestInit): Promise<T> {
85+ return this.json<T>(this.session.server.site + path, init, "site");
86+ }
87+
88+ /** The current access token, refreshed first when it is about to expire. */
89+ async token(): Promise<string> {
90+ const credentials = this.session.credentials;
91+ if (credentials.kind === "oauth" && credentials.expiresAt - REFRESH_EARLY_MS < Date.now()) {
92+ return (await this.refreshOnce()).accessToken;
93+ }
94+ return credentials.accessToken;
95+ }
96+
97+ private refreshOnce(): Promise<Credentials> {
98+ const credentials = this.session.credentials;
99+ if (credentials.kind !== "oauth") return Promise.resolve(credentials);
100+ this.refreshing ??= refresh(this.session.server, credentials)
101+ .then(async (next) => {
102+ this.session = { ...this.session, credentials: next };
103+ await saveCredentials(next);
104+ this.session.onCredentials(next);
105+ return next;
106+ })
107+ .catch((error) => {
108+ if (error instanceof SignedOut) this.session.onCredentials(null);
109+ throw error instanceof SignedOut ? new ApiError("signed_out", error.message, 401) : error;
110+ })
111+ .finally(() => {
112+ this.refreshing = null;
113+ });
114+ return this.refreshing;
115+ }
116+
117+ private async json<T>(url: string, init: RequestInit | undefined, target: "api" | "site", retried = false): Promise<T> {
118+ const token = await this.token();
119+ let response: Response;
120+ try {
121+ response = await fetch(url, {
122+ ...init,
123+ headers: {
124+ accept: "application/json",
125+ ...(init?.body ? { "content-type": "application/json" } : {}),
126+ ...init?.headers,
127+ authorization: `Bearer ${token}`,
128+ },
129+ });
130+ } catch {
131+ throw new ApiError("offline", "Could not reach g1t. Check your connection.");
132+ }
133+ if (response.status === 401) {
134+ // An expired or revoked OAuth token gets one refresh; the site refusing
135+ // a token it will never take is not that.
136+ const refusedBySite = target === "site" && (await this.siteRefusesToken(response));
137+ if (refusedBySite) {
138+ throw new ApiError(
139+ "website_refused",
140+ this.session.credentials.kind === "oauth"
141+ ? "Chat is not open to the app's sign-in on this g1t yet. Sign in with an access token that can use the website, from You → Sign in another way."
142+ : "This access token cannot use the website. Turn on “Use the website as you” for it in Settings → Access tokens.",
143+ 401,
144+ );
145+ }
146+ if (!retried && this.session.credentials.kind === "oauth") {
147+ await this.refreshOnce();
148+ return this.json<T>(url, init, target, true);
149+ }
150+ this.session.onCredentials(null);
151+ throw new ApiError("signed_out", "You were signed out. Sign in again.", 401);
152+ }
153+ if (response.status === 404) throw new ApiError("not_found", "Not found.", 404);
154+ if (response.status === 403) throw new ApiError("forbidden", await messageOf(response, "You do not have access to this."), 403);
155+ if (!response.ok) throw new ApiError("failed", await messageOf(response, `g1t answered ${response.status}.`), response.status);
156+ return (await response.json()) as T;
157+ }
158+
159+ /**
160+ * Whether a 401 from the site is it refusing the token itself (its
161+ * `WWW-Authenticate` names `invalid_token`; apps/web lib/website-token.ts).
162+ * The token may still be good for the API: check before signing out.
163+ */
164+ private async siteRefusesToken(response: Response): Promise<boolean> {
165+ if (!/invalid_token/.test(response.headers.get("www-authenticate") ?? "")) return false;
166+ try {
167+ const check = await fetch(`${this.session.server.api}/user`, {
168+ headers: { authorization: `Bearer ${this.session.credentials.accessToken}` },
169+ });
170+ return check.ok;
171+ } catch {
172+ return false;
173+ }
174+ }
175+}
176+
177+async function messageOf(response: Response, fallback: string): Promise<string> {
178+ try {
179+ const body = (await response.clone().json()) as { message?: string; error?: { message?: string }; error_description?: string };
180+ return body.message ?? body.error?.message ?? body.error_description ?? fallback;
181+ } catch {
182+ const text = await response.text().catch(() => "");
183+ return text.trim().slice(0, 300) || fallback;
184+ }
185+}
+167−0
1+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2+
3+import { useSignedIn } from "./session";
4+
5+/**
6+ * Code, through the REST API (docs.g1t.sh/reference/api/). The API
7+ * answers in snake_case, so these are its shapes, not the camelCase ones
8+ * in @g1t/contracts that the services use among themselves. Only the
9+ * fields the app shows are named.
10+ */
11+
12+export type ApiUser = { id: string; username: string; display_username?: string; kind?: "user" | "workspace" | "agent" | "system" };
13+
14+export type ApiRepo = {
15+ id: string;
16+ namespace: string;
17+ name: string;
18+ description: string | null;
19+ is_private: boolean;
20+ default_branch: string;
21+ created_at: string;
22+ archived_at?: string | null;
23+};
24+
25+export type ApiIssue = {
26+ id: string;
27+ number: number;
28+ title: string;
29+ body: string;
30+ labels: string[];
31+ state: "open" | "closed";
32+ reason: "completed" | "not_planned" | null;
33+ author: ApiUser;
34+ created_at: string;
35+ updated_at: string;
36+ comment_count: number;
37+ pull_count: number;
38+};
39+
40+export type CheckStatus = "queued" | "running" | "passed" | "failed" | "errored" | null;
41+
42+export type ApiPull = {
43+ id: string;
44+ number: number;
45+ issue: number | null;
46+ title: string;
47+ body: string | null;
48+ agent: string;
49+ status: "draft" | "open" | "merged" | "closed";
50+ branch: string | null;
51+ base: string;
52+ check_status: CheckStatus;
53+ files: { path: string; additions: number; deletions: number }[];
54+ reviewers: string[];
55+ labels: string[];
56+ author: ApiUser;
57+ created_at: string;
58+ updated_at: string;
59+ merged_at: string | null;
60+};
61+
62+export type ApiComment = {
63+ id: string;
64+ kind: "comment" | "event";
65+ author: ApiUser;
66+ body: string;
67+ path: string | null;
68+ line: number | null;
69+ verdict: string | null;
70+ created_at: string;
71+};
72+
73+export type ApiChanges = {
74+ base: string;
75+ head: string;
76+ truncated: boolean;
77+ files: {
78+ path: string;
79+ status: "added" | "modified" | "deleted";
80+ additions: number;
81+ deletions: number;
82+ binary: boolean;
83+ hunks?: { lines: { kind: "context" | "add" | "delete"; old: number | null; new: number | null; text: string }[] }[];
84+ }[];
85+};
86+
87+const repoPath = (owner: string, name: string) => `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
88+
89+/** Repositories you can see, newest first (at most 50), in the workspace chosen. */
90+export function useRepos(search: string) {
91+ const { client, workspace } = useSignedIn();
92+ return useQuery({
93+ queryKey: ["code", "repos", search],
94+ queryFn: () => client.api<ApiRepo[]>(`/repos${search ? `?q=${encodeURIComponent(search)}` : ""}`),
95+ select: (repos) => (workspace ? repos.filter((repo) => repo.namespace.toLowerCase() === workspace.slug.toLowerCase()) : repos),
96+ });
97+}
98+
99+export function useRepo(owner: string, name: string) {
100+ const { client } = useSignedIn();
101+ return useQuery({ queryKey: ["code", "repo", owner, name], queryFn: () => client.api<ApiRepo>(repoPath(owner, name)) });
102+}
103+
104+export function usePulls(owner: string, name: string, state: "open" | "closed" | "all") {
105+ const { client } = useSignedIn();
106+ return useQuery({
107+ queryKey: ["code", "pulls", owner, name, state],
108+ queryFn: () => client.api<ApiPull[]>(`${repoPath(owner, name)}/pulls?state=${state}`),
109+ });
110+}
111+
112+export function useIssues(owner: string, name: string, state: "open" | "closed" | "all") {
113+ const { client } = useSignedIn();
114+ return useQuery({
115+ queryKey: ["code", "issues", owner, name, state],
116+ queryFn: () => client.api<ApiIssue[]>(`${repoPath(owner, name)}/issues?state=${state}`),
117+ });
118+}
119+
120+export function usePull(owner: string, name: string, number: number) {
121+ const { client } = useSignedIn();
122+ return useQuery({
123+ queryKey: ["code", "pull", owner, name, number],
124+ queryFn: () =>
125+ client.api<{ pull: ApiPull; issue: ApiIssue | null; comments: ApiComment[] }>(`${repoPath(owner, name)}/pulls/${number}`),
126+ });
127+}
128+
129+export function usePullChanges(owner: string, name: string, number: number, enabled: boolean) {
130+ const { client } = useSignedIn();
131+ return useQuery({
132+ queryKey: ["code", "pull-changes", owner, name, number],
133+ queryFn: () => client.api<ApiChanges>(`${repoPath(owner, name)}/pulls/${number}/changes`),
134+ enabled,
135+ });
136+}
137+
138+export function useIssue(owner: string, name: string, number: number) {
139+ const { client } = useSignedIn();
140+ return useQuery({
141+ queryKey: ["code", "issue", owner, name, number],
142+ queryFn: () =>
143+ client.api<{ issue: ApiIssue; pulls: ApiPull[]; comments: ApiComment[] }>(`${repoPath(owner, name)}/issues/${number}`),
144+ });
145+}
146+
147+/** Comments on an issue (`POST /repos/{owner}/{name}/issues/{number}/comments`). */
148+export function useComment(owner: string, name: string, number: number) {
149+ const { client } = useSignedIn();
150+ const queries = useQueryClient();
151+ return useMutation({
152+ mutationFn: (body: string) =>
153+ client.api<ApiComment>(`${repoPath(owner, name)}/issues/${number}/comments`, { method: "POST", body: JSON.stringify({ body }) }),
154+ onSuccess: () => queries.invalidateQueries({ queryKey: ["code", "issue", owner, name, number] }),
155+ });
156+}
157+
158+/** Reviews a pull request (`POST /repos/{owner}/{name}/pulls/{number}/reviews`). */
159+export function useReview(owner: string, name: string, number: number) {
160+ const { client } = useSignedIn();
161+ const queries = useQueryClient();
162+ return useMutation({
163+ mutationFn: (review: { verdict: "approve" | "request_changes"; body: string }) =>
164+ client.api<ApiComment>(`${repoPath(owner, name)}/pulls/${number}/reviews`, { method: "POST", body: JSON.stringify(review) }),
165+ onSuccess: () => queries.invalidateQueries({ queryKey: ["code", "pull", owner, name, number] }),
166+ });
167+}
+67−0
1+import * as SecureStore from "expo-secure-store";
2+
3+import { HOSTED, type Server } from "./server";
4+
5+/**
6+ * What the app keeps between launches, in the phone's keystore (Android
7+ * Keystore, the iOS keychain): the server, and how it is signed in.
8+ *
9+ * Two ways to be signed in:
10+ * - `oauth`: the app's own sign-in (lib/oauth.ts). An access token that
11+ * lasts 30 days and a refresh token that works once and returns the
12+ * next pair, as every OAuth client of g1t's gets.
13+ * - `token`: a personal access token pasted in, for a g1t whose OAuth
14+ * tokens do not yet open Chat (see the README) or for testing. One with
15+ * "Use the website as you" turned on opens everything the app shows.
16+ */
17+export type Credentials =
18+ | {
19+ kind: "oauth";
20+ accessToken: string;
21+ refreshToken: string;
22+ /** Epoch milliseconds. */
23+ expiresAt: number;
24+ /** The scopes the person granted, or `*` for full access. */
25+ scope: string;
26+ /**
27+ * The client id it was issued to: a refresh must name the same one.
28+ * Kept, because in Expo Go the id follows the computer's address,
29+ * which changes between sessions (lib/oauth.ts).
30+ */
31+ clientId?: string;
32+ }
33+ | { kind: "token"; accessToken: string };
34+
35+const SERVER_KEY = "g1t.server";
36+const CREDENTIALS_KEY = "g1t.credentials";
37+
38+async function readJson<T>(key: string): Promise<T | null> {
39+ try {
40+ const text = await SecureStore.getItemAsync(key);
41+ return text ? (JSON.parse(text) as T) : null;
42+ } catch {
43+ // Unreadable (a restore to another phone, a keystore reset): start over.
44+ return null;
45+ }
46+}
47+
48+export async function loadServer(): Promise<Server> {
49+ const saved = await readJson<Server>(SERVER_KEY);
50+ return saved?.site && saved?.api ? saved : HOSTED;
51+}
52+
53+export async function saveServer(server: Server): Promise<void> {
54+ await SecureStore.setItemAsync(SERVER_KEY, JSON.stringify(server));
55+}
56+
57+export async function loadCredentials(): Promise<Credentials | null> {
58+ return readJson<Credentials>(CREDENTIALS_KEY);
59+}
60+
61+export async function saveCredentials(credentials: Credentials): Promise<void> {
62+ await SecureStore.setItemAsync(CREDENTIALS_KEY, JSON.stringify(credentials));
63+}
64+
65+export async function clearCredentials(): Promise<void> {
66+ await SecureStore.deleteItemAsync(CREDENTIALS_KEY);
67+}
+58−0
1+import { connectorPath, connectorsFor } from "@g1t/contracts/connectors";
2+import { CUSTOMIZE, type CustomizeSection, customizePath } from "@g1t/contracts/customize";
3+import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace";
4+import { FOUNDATIONAL_SKILLS } from "@g1t/contracts/skills";
5+
6+/**
7+ * Customize on the phone: Skills, Connectors and Plugins
8+ * (packages/contracts/src/customize.ts). What is listed comes from the
9+ * catalogs every g1t ships with; a workspace's own library, connections
10+ * and installs are read on the site for now, and added here once the API
11+ * serves them.
12+ */
13+
14+export type CustomizeItem = {
15+ id: string;
16+ name: string;
17+ description: string;
18+ /** Available, Soon, or what else a listing says about itself. */
19+ status: "available" | "soon" | "built-in";
20+ /** A group to list it under. */
21+ group: string;
22+ /** The site page that sets it up, for this workspace. */
23+ sitePath: string;
24+};
25+
26+export function customizeItems(section: CustomizeSection, workspace: string): CustomizeItem[] {
27+ const fallback = customizePath(section, workspace);
28+ if (section === "skills") {
29+ return FOUNDATIONAL_SKILLS.map((skill) => ({
30+ id: skill.id,
31+ name: skill.name,
32+ description: skill.description,
33+ status: "built-in" as const,
34+ group: "In every agent",
35+ sitePath: fallback,
36+ }));
37+ }
38+ if (section === "connectors") {
39+ return connectorsFor("workspace").map((connector) => ({
40+ id: connector.id,
41+ name: connector.name,
42+ description: connector.description,
43+ status: connector.status,
44+ group: connector.status === "available" ? "Connect now" : "Coming",
45+ sitePath: connector.href ? connectorPath(connector.href, workspace) : `${fallback}/${encodeURIComponent(connector.id)}`,
46+ }));
47+ }
48+ return FIRST_PARTY_EXTENSIONS.map((extension) => ({
49+ id: extension.id,
50+ name: extension.name,
51+ description: extension.tagline,
52+ status: extension.status,
53+ group: `${extension.publisher.name} · ${extension.category}`,
54+ sitePath: `${fallback}/${encodeURIComponent(extension.id)}`,
55+ }));
56+}
57+
58+export { CUSTOMIZE, customizePath, type CustomizeSection };
+126−0
1+import type { ChatSidebar, FeedEvent, FeedNotification, Presence } from "@g1t/contracts";
2+import { useQueryClient } from "@tanstack/react-query";
3+import * as Haptics from "expo-haptics";
4+import * as Notifications from "expo-notifications";
5+import { usePathname } from "expo-router";
6+import { type ReactNode, createContext, useContext, useEffect, useRef, useState } from "react";
7+import { AppState } from "react-native";
8+
9+import { chatKeys } from "./chat";
10+import { openSocket } from "./live";
11+import { useSignedIn } from "./session";
12+import { viewing } from "./viewing";
13+
14+/**
15+ * The person's feed, while the app is in front: the same socket every tab
16+ * of the site keeps open (`/-/live?workspace=<slug>`, services/notify).
17+ * It brings unread counts as they move, notifications as they happen (a
18+ * banner unless the conversation is open), and who is online. The app
19+ * says it is focused, so g1t pushes nothing to the phone while it is in
20+ * use; in the background the socket closes and pushes take over.
21+ */
22+
23+type FeedState = {
24+ /** Unread in Chat, leaving out muted conversations; mentions apart. */
25+ unread: number;
26+ mentions: number;
27+ /** A notification to show as a banner, until it is dismissed. */
28+ banner: FeedNotification | null;
29+ dismiss: () => void;
30+ presence: Map<string, Presence>;
31+};
32+
33+const FeedContext = createContext<FeedState>({ unread: 0, mentions: 0, banner: null, dismiss: () => {}, presence: new Map() });
34+
35+export function FeedProvider({ children }: { children: ReactNode }) {
36+ const { client, workspace } = useSignedIn();
37+ const queries = useQueryClient();
38+ const slug = workspace?.slug ?? "";
39+ const path = usePathname();
40+ const [active, setActive] = useState(AppState.currentState === "active");
41+ const [counts, setCounts] = useState({ unread: 0, mentions: 0 });
42+ const [banner, setBanner] = useState<FeedNotification | null>(null);
43+ const [presence, setPresence] = useState<Map<string, Presence>>(new Map());
44+ const sendRef = useRef<((frame: unknown) => void) | null>(null);
45+ const pathRef = useRef(path);
46+ pathRef.current = path;
47+
48+ useEffect(() => {
49+ const subscription = AppState.addEventListener("change", (state) => {
50+ // Say so before the socket closes: a phone can freeze it rather than close
51+ // it, and g1t pushes nothing while a socket says it is in front (for up to
52+ // 70 s after it last spoke).
53+ if (state !== "active") sendRef.current?.({ type: "state", focused: false, path: pathRef.current });
54+ setActive(state === "active");
55+ });
56+ return () => subscription.remove();
57+ }, []);
58+
59+ useEffect(() => {
60+ if (!active || !slug) return;
61+ const socket = openSocket(client, "/-/live", { workspace: slug }, {
62+ onOpen: (send) => {
63+ sendRef.current = send;
64+ send({ type: "state", focused: true, path: pathRef.current });
65+ },
66+ onReconnect: () => void queries.invalidateQueries({ queryKey: chatKeys.all(slug) }),
67+ onMessage: (data) => {
68+ const event = data as FeedEvent;
69+ if (event.type === "counts" && event.workspace === slug) {
70+ setCounts({ unread: event.chat_unread, mentions: event.chat_mentions });
71+ void Notifications.setBadgeCountAsync(event.chat_unread + event.chat_mentions).catch(() => undefined);
72+ const per = new Map(event.per_channel.map((row) => [row.channel_id, row]));
73+ queries.setQueryData<ChatSidebar>(chatKeys.sidebar(slug), (sidebar) => {
74+ if (!sidebar) return sidebar;
75+ const known = new Set(sidebar.entries.map((entry) => entry.channel.id));
76+ // A conversation the app has not seen yet: read the sidebar again.
77+ if ([...per.keys()].some((id) => !known.has(id))) void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
78+ return {
79+ ...sidebar,
80+ entries: sidebar.entries.map((entry) => {
81+ const row = per.get(entry.channel.id);
82+ if (row) return { ...entry, unread: row.unread, mentions: row.mentions };
83+ return event.complete ? { ...entry, unread: 0, mentions: 0 } : entry;
84+ }),
85+ };
86+ });
87+ } else if (event.type === "notification" && event.toast) {
88+ const note = event.notification;
89+ if (note.workspace !== slug || (note.channel_id && note.channel_id === viewing.channelId)) return;
90+ setBanner(note);
91+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => undefined);
92+ } else if (event.type === "presence" && event.workspace === slug) {
93+ setPresence((before) => {
94+ const next = event.full ? new Map<string, Presence>() : new Map(before);
95+ for (const person of event.people) next.set(person.user_id, person.presence);
96+ return next;
97+ });
98+ }
99+ },
100+ });
101+ return () => {
102+ sendRef.current = null;
103+ socket.close();
104+ };
105+ }, [active, client, queries, slug]);
106+
107+ // Where the app is, as a tab says where it is.
108+ useEffect(() => {
109+ sendRef.current?.({ type: "state", focused: true, path });
110+ }, [path]);
111+
112+ // A banner goes by itself.
113+ useEffect(() => {
114+ if (!banner) return;
115+ const timer = setTimeout(() => setBanner(null), 5000);
116+ return () => clearTimeout(timer);
117+ }, [banner]);
118+
119+ return (
120+ <FeedContext.Provider value={{ ...counts, banner, dismiss: () => setBanner(null), presence }}>{children}</FeedContext.Provider>
121+ );
122+}
123+
124+export function useFeed(): FeedState {
125+ return useContext(FeedContext);
126+}
+37−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { routeFor, sitePathOf } from "./links.ts";
5+
6+test("a g1t:// link names a site path, as on the desktop", () => {
7+ assert.equal(sitePathOf("g1t://acme/-/chat/dm/chn_1"), "/acme/-/chat/dm/chn_1");
8+ assert.equal(sitePathOf("g1t:///acme/rocket/pull/3?tab=files"), "/acme/rocket/pull/3?tab=files");
9+ assert.equal(sitePathOf("https://g1t.sh/acme"), null);
10+});
11+
12+test("Chat's pages open the app's Chat", () => {
13+ assert.equal(routeFor("/acme/-/chat"), "/chat?workspace=acme");
14+ assert.equal(routeFor("/acme/-/chat/general"), "/chat/general?workspace=acme");
15+ assert.equal(routeFor("/acme/-/chat/dm/chn_1"), "/chat/chn_1?workspace=acme");
16+ assert.equal(routeFor("/acme/-/chat/browse"), null);
17+});
18+
19+test("Code's pages open the app's Code", () => {
20+ assert.equal(routeFor("/acme/rocket"), "/code/acme/rocket");
21+ assert.equal(routeFor("/acme/rocket/pull/14"), "/code/acme/rocket/pull/14");
22+ assert.equal(routeFor("/acme/rocket/issues/12"), "/code/acme/rocket/issues/12");
23+ assert.equal(routeFor("/acme/-/overview"), "/code?workspace=acme");
24+ assert.equal(routeFor("/acme/rocket/pull/14/files"), null);
25+ assert.equal(routeFor("/acme/rocket/issues/new"), null);
26+});
27+
28+test("the Marketplace, integrations and skills open Customize", () => {
29+ assert.equal(routeFor("/acme/-/marketplace/integrations"), "/customize?workspace=acme");
30+ assert.equal(routeFor("/acme/-/agents/skills"), "/customize?workspace=acme");
31+});
32+
33+test("the app's sign-in comes back to its own screen, and the site's own pages stay on the site", () => {
34+ assert.equal(routeFor("/oauth/callback?code=c&state=s"), "/oauth/callback?code=c&state=s");
35+ assert.equal(routeFor("/settings/tokens"), null);
36+ assert.equal(routeFor("/"), "/");
37+});
+72−0
1+/**
2+ * Links into the app. A `g1t://` link names a site path, as on the desktop
3+ * (apps/desktop/src-tauri/src/links.rs): `g1t://acme/-/chat/dm/chn_1` is
4+ * the page `/acme/-/chat/dm/chn_1`. The app has screens for some of the
5+ * site's pages; `routeFor` says which screen shows a site path, and the
6+ * rest open on the site.
7+ *
8+ * Kept free of React Native imports so `npm test` runs it under Node.
9+ */
10+
11+/** Where the app's OAuth sign-in comes back to. */
12+export const OAUTH_CALLBACK_PATH = "/oauth/callback";
13+
14+/** Path segments a workspace or account can never be named (the site's own pages). */
15+const NOT_OWNERS = new Set(["settings", "notifications", "oauth", "device", "login", "register", "-"]);
16+
17+/**
18+ * The app's screen for a site path, or null when the app has none and the
19+ * page should open on the site.
20+ */
21+export function routeFor(sitePath: string): string | null {
22+ const [pathname, query = ""] = sitePath.split("?", 2);
23+ const parts = pathname.split("/").filter(Boolean).map(decodeURIComponentSafe);
24+ const keep = query ? `?${query}` : "";
25+ if (parts.length === 0) return "/";
26+ if (`/${parts.join("/")}` === OAUTH_CALLBACK_PATH) return OAUTH_CALLBACK_PATH + keep;
27+ const [owner, second, third, fourth, fifth] = parts;
28+ if (NOT_OWNERS.has(owner.toLowerCase())) return null;
29+ const workspace = `workspace=${encodeURIComponent(owner)}`;
30+
31+ // A workspace's own pages: /<workspace>/-/<page>.
32+ if (second === "-") {
33+ if (third === "chat") {
34+ if (!fourth) return `/chat?${workspace}`;
35+ if (fourth === "dm" && fifth) return `/chat/${encodeURIComponent(fifth)}?${workspace}`;
36+ if (fourth !== "browse" && fourth !== "dm" && !fifth) return `/chat/${encodeURIComponent(fourth)}?${workspace}`;
37+ return null;
38+ }
39+ if (third === "overview" && !fourth) return `/code?${workspace}`;
40+ if (third === "marketplace" || third === "integrations" || (third === "agents" && fourth === "skills")) {
41+ return `/customize?${workspace}`;
42+ }
43+ return null;
44+ }
45+
46+ // A repository's pages: /<owner>/<repo>[/pull/<n> | /issues/<n>].
47+ if (!second) return null;
48+ const repo = `/code/${encodeURIComponent(owner)}/${encodeURIComponent(second)}`;
49+ if (!third) return repo;
50+ if (third === "pull" && fourth && /^\d+$/.test(fourth) && !fifth) return `${repo}/pull/${fourth}`;
51+ if (third === "issues" && fourth && /^\d+$/.test(fourth) && !fifth) return `${repo}/issues/${fourth}`;
52+ return null;
53+}
54+
55+/**
56+ * The site path a `g1t://` link names, or null for any other link.
57+ * `g1t://acme/-/chat?x` is `/acme/-/chat?x`.
58+ */
59+export function sitePathOf(link: string): string | null {
60+ const match = /^g1t:\/\/([^?#]*)(\?[^#]*)?/i.exec(link);
61+ if (!match) return null;
62+ const path = `/${match[1].replace(/^\/+/, "")}`;
63+ return path + (match[2] ?? "");
64+}
65+
66+function decodeURIComponentSafe(part: string): string {
67+ try {
68+ return decodeURIComponent(part);
69+ } catch {
70+ return part;
71+ }
72+}
+100−0
1+import type { Client } from "./client";
2+import { socketUrl } from "./server";
3+
4+/**
5+ * One of the site's live sockets, kept open: a conversation's
6+ * (`/<workspace>/-/chat/live`) or the person's feed (`/-/live`). A socket
7+ * cannot carry the `Authorization` header, so each opening asks
8+ * `GET /-/live/ticket?path=<path>` for a ticket (good for a minute, for
9+ * that socket alone; apps/web/app/lib/socket-ticket.ts) and adds it to
10+ * the address. A dropped socket opens again, with a new ticket, waiting
11+ * longer each time up to half a minute; a `ping` every 25 s keeps it
12+ * from being taken for gone.
13+ */
14+
15+export type SocketStatus = "connecting" | "live" | "offline";
16+
17+type Handlers = {
18+ onMessage: (data: unknown) => void;
19+ onStatus?: (status: SocketStatus) => void;
20+ /** Opened again after a drop: what was missed meanwhile should be read again. */
21+ onReconnect?: () => void;
22+ onOpen?: (send: (frame: unknown) => void) => void;
23+};
24+
25+const PING_MS = 25_000;
26+
27+export function openSocket(client: Client, path: string, query: Record<string, string>, handlers: Handlers) {
28+ let socket: WebSocket | null = null;
29+ let closed = false;
30+ let attempts = 0;
31+ let opened = false;
32+ let retry: ReturnType<typeof setTimeout> | null = null;
33+ let ping: ReturnType<typeof setInterval> | null = null;
34+
35+ const send = (frame: unknown) => {
36+ if (socket?.readyState === WebSocket.OPEN) socket.send(typeof frame === "string" ? frame : JSON.stringify(frame));
37+ };
38+
39+ const again = () => {
40+ if (ping) clearInterval(ping);
41+ ping = null;
42+ if (closed) return;
43+ handlers.onStatus?.("offline");
44+ attempts += 1;
45+ retry = setTimeout(open, Math.min(30_000, 1_000 * 2 ** Math.min(attempts - 1, 5)));
46+ };
47+
48+ async function open() {
49+ if (closed) return;
50+ handlers.onStatus?.("connecting");
51+ let ticket: string;
52+ try {
53+ const token = await client.token();
54+ const response = await fetch(`${client.server.site}/-/live/ticket?path=${encodeURIComponent(path)}`, {
55+ headers: { authorization: `Bearer ${token}`, accept: "application/json" },
56+ });
57+ if (!response.ok) throw new Error(`ticket ${response.status}`);
58+ ticket = ((await response.json()) as { ticket: string }).ticket;
59+ } catch {
60+ return again();
61+ }
62+ if (closed) return;
63+ const address = `${socketUrl(client.server, path)}?${new URLSearchParams({ ...query, ticket })}`;
64+ const next = new WebSocket(address);
65+ socket = next;
66+ next.onopen = () => {
67+ const wasOpened = opened;
68+ opened = true;
69+ attempts = 0;
70+ handlers.onStatus?.("live");
71+ ping = setInterval(() => send("ping"), PING_MS);
72+ handlers.onOpen?.(send);
73+ if (wasOpened) handlers.onReconnect?.();
74+ };
75+ next.onmessage = (event) => {
76+ if (event.data === "pong") return;
77+ try {
78+ handlers.onMessage(JSON.parse(String(event.data)));
79+ } catch {
80+ // Not JSON: nothing for the app.
81+ }
82+ };
83+ next.onclose = () => {
84+ if (socket === next) again();
85+ };
86+ }
87+
88+ void open();
89+
90+ return {
91+ send,
92+ close() {
93+ closed = true;
94+ if (retry) clearTimeout(retry);
95+ if (ping) clearInterval(ping);
96+ socket?.close();
97+ socket = null;
98+ },
99+ };
100+}
+183−0
1+import { encodeOAuthClient } from "@g1t/contracts/oauth";
2+import Constants, { ExecutionEnvironment } from "expo-constants";
3+import * as AuthSession from "expo-auth-session";
4+import * as WebBrowser from "expo-web-browser";
5+import { Platform } from "react-native";
6+
7+import type { Credentials } from "./credentials";
8+import { OAUTH_CALLBACK_PATH } from "./links";
9+import type { Server } from "./server";
10+
11+/**
12+ * Signing in with g1t's OAuth 2.1, as any public client does
13+ * (docs.g1t.sh/guides/authentication/#signing-in-with-oauth): the
14+ * authorization code flow with PKCE (S256), no client secret, and a client
15+ * id that encodes the app's name and where it is sent back, so nothing is
16+ * registered first.
17+ *
18+ * The person approves on the site, in the system's browser, which keeps
19+ * their g1t session (and passkeys, password managers, two-factor) out of
20+ * the app. The site sends them back to `g1t://oauth/callback`.
21+ *
22+ * The app appears in Settings → Connected applications as "g1t for
23+ * Android" or "g1t for iOS", where its access can be narrowed or ended.
24+ */
25+
26+// Lets the browser hand its result back when the app was in the background.
27+WebBrowser.maybeCompleteAuthSession();
28+
29+/**
30+ * What the app asks for: what Chat, Code and Customize show and do. The
31+ * person can untick any of these on the approval page. `chat:*` opens
32+ * Chat's routes and live sockets on the site, and `notifications:*` the
33+ * phone's registration for pushes (`/-/notify`), and nothing else there.
34+ */
35+export const SCOPES = [
36+ "chat:read",
37+ "chat:write",
38+ "account:read",
39+ "workspace:read",
40+ "repo:read",
41+ "code:read",
42+ "issues:read",
43+ "issues:write",
44+ "pull_requests:read",
45+ "pull_requests:write",
46+ "checks:read",
47+ "workflows:read",
48+ "notifications:read",
49+ "notifications:write",
50+ "agents:run",
51+ "memory:read",
52+ "artifacts:read",
53+];
54+
55+/** Whether the app is running inside Expo Go, while developing. */
56+export const IN_EXPO_GO = Constants.executionEnvironment === ExecutionEnvironment.StoreClient;
57+
58+/** The name shown on the approval page and in Connected applications. */
59+const CLIENT_NAME = IN_EXPO_GO ? "g1t in Expo Go" : Platform.OS === "ios" ? "g1t for iOS" : "g1t for Android";
60+
61+/**
62+ * Where the site sends the person back: `g1t://oauth/callback` in a build
63+ * of the app, and Expo Go's own address while developing in it
64+ * (`exp://192.168.1.20:8081/--/oauth/callback`), which g1t accepts as an
65+ * application's own scheme. Since the client id encodes it, Go's id
66+ * changes with the computer's address; credentials keep the id they were
67+ * issued to, for refreshing.
68+ */
69+export function redirectUri(): string {
70+ return AuthSession.makeRedirectUri({ scheme: "g1t", path: OAUTH_CALLBACK_PATH.slice(1) });
71+}
72+
73+export function clientId(redirect: string): string {
74+ const id = encodeOAuthClient({ name: CLIENT_NAME, redirectUris: [redirect] });
75+ if (!id) throw new Error(`g1t does not accept ${redirect} as a place to send you back.`);
76+ return id;
77+}
78+
79+type Metadata = {
80+ issuer: string;
81+ authorization_endpoint: string;
82+ token_endpoint: string;
83+ revocation_endpoint?: string;
84+};
85+
86+/** The server's OAuth metadata (RFC 8414), from its API. */
87+async function metadata(server: Server): Promise<Metadata> {
88+ const response = await fetch(`${server.api}/.well-known/oauth-authorization-server`, {
89+ headers: { accept: "application/json" },
90+ });
91+ if (!response.ok) throw new Error(`${server.api} did not say how to sign in (${response.status}).`);
92+ return (await response.json()) as Metadata;
93+}
94+
95+function discovery(meta: Metadata): AuthSession.DiscoveryDocument {
96+ return {
97+ authorizationEndpoint: meta.authorization_endpoint,
98+ tokenEndpoint: meta.token_endpoint,
99+ revocationEndpoint: meta.revocation_endpoint,
100+ };
101+}
102+
103+export class SignInCancelled extends Error {
104+ constructor() {
105+ super("Sign-in was cancelled.");
106+ }
107+}
108+
109+/**
110+ * Opens the site's approval page and, once the person approves, trades the
111+ * code for tokens. Throws {@link SignInCancelled} when they close the page
112+ * or deny.
113+ */
114+export async function signInWithOAuth(server: Server): Promise<Credentials> {
115+ const meta = await metadata(server);
116+ const redirect = redirectUri();
117+ const id = clientId(redirect);
118+ const request = new AuthSession.AuthRequest({
119+ clientId: id,
120+ redirectUri: redirect,
121+ scopes: SCOPES,
122+ usePKCE: true,
123+ codeChallengeMethod: AuthSession.CodeChallengeMethod.S256,
124+ });
125+ const result = await request.promptAsync(discovery(meta));
126+ if (result.type === "cancel" || result.type === "dismiss") throw new SignInCancelled();
127+ if (result.type !== "success") {
128+ const denied = result.type === "error" && result.params.error === "access_denied";
129+ if (denied) throw new SignInCancelled();
130+ throw new Error(result.type === "error" ? (result.params.error_description ?? result.params.error ?? "Sign-in failed.") : "Sign-in failed.");
131+ }
132+ // RFC 9207: the code must come from the server the app asked.
133+ if (result.params.iss && result.params.iss.replace(/\/$/, "") !== meta.issuer.replace(/\/$/, "")) {
134+ throw new Error("The sign-in came back from a different server than the one asked. Try again.");
135+ }
136+ const tokens = await AuthSession.exchangeCodeAsync(
137+ {
138+ clientId: id,
139+ code: result.params.code,
140+ redirectUri: redirect,
141+ extraParams: { code_verifier: request.codeVerifier ?? "" },
142+ },
143+ discovery(meta),
144+ );
145+ return fromTokenResponse(tokens, id);
146+}
147+
148+function fromTokenResponse(tokens: AuthSession.TokenResponse, clientId: string): Credentials {
149+ if (!tokens.refreshToken) throw new Error("g1t did not return a refresh token.");
150+ return {
151+ kind: "oauth",
152+ accessToken: tokens.accessToken,
153+ refreshToken: tokens.refreshToken,
154+ expiresAt: (tokens.issuedAt + (tokens.expiresIn ?? 30 * 24 * 3600)) * 1000,
155+ scope: tokens.scope ?? "",
156+ clientId,
157+ };
158+}
159+
160+/** Raised when the refresh token no longer works: the person must sign in again. */
161+export class SignedOut extends Error {
162+ constructor(message = "You were signed out. Sign in again.") {
163+ super(message);
164+ }
165+}
166+
167+/**
168+ * The next pair of tokens. A refresh token works once: the old access
169+ * token stops working, so callers share one refresh at a time
170+ * (lib/client.ts).
171+ */
172+export async function refresh(server: Server, credentials: Extract<Credentials, { kind: "oauth" }>): Promise<Credentials> {
173+ const meta = await metadata(server);
174+ const id = credentials.clientId ?? clientId(redirectUri());
175+ try {
176+ const tokens = await AuthSession.refreshAsync({ clientId: id, refreshToken: credentials.refreshToken }, discovery(meta));
177+ return fromTokenResponse(tokens, id);
178+ } catch (error) {
179+ // invalid_grant: used, expired or revoked in Connected applications.
180+ if (error instanceof AuthSession.TokenError) throw new SignedOut();
181+ throw error;
182+ }
183+}
+113−0
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+}
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+}
+32−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { HOSTED, apiFor, parseOrigin, parseServer, socketUrl } from "./server.ts";
5+
6+test("an origin is all that is kept", () => {
7+ assert.equal(parseOrigin("https://g1t.sh/acme/-/chat?x#y"), "https://g1t.sh");
8+ assert.equal(parseOrigin(" g1t.example.com "), "https://g1t.example.com");
9+ assert.equal(parseOrigin("http://192.168.1.20:5173/login"), "http://192.168.1.20:5173");
10+ assert.equal(parseOrigin("ftp://g1t.sh"), null);
11+ assert.equal(parseOrigin("not a url at all"), null);
12+ assert.equal(parseOrigin(""), null);
13+});
14+
15+test("hosted g1t's API is api.g1t.sh, and a self-hosted one's is api. on its host", () => {
16+ assert.equal(apiFor("https://g1t.sh"), "https://api.g1t.sh");
17+ assert.equal(apiFor("https://g1t.example.com"), "https://api.g1t.example.com");
18+ assert.deepEqual(parseServer("g1t.sh"), HOSTED);
19+});
20+
21+test("an API given by hand wins", () => {
22+ assert.deepEqual(parseServer("http://localhost:5173", "http://localhost:8789/"), {
23+ site: "http://localhost:5173",
24+ api: "http://localhost:8789",
25+ });
26+ assert.equal(parseServer("g1t.sh", "javascript:alert(1)"), null);
27+});
28+
29+test("sockets follow the site's scheme", () => {
30+ assert.equal(socketUrl(HOSTED, "/acme/-/chat/live?channel=chn_1"), "wss://g1t.sh/acme/-/chat/live?channel=chn_1");
31+ assert.equal(socketUrl({ site: "http://localhost:5173", api: "" }, "/-/live"), "ws://localhost:5173/-/live");
32+});
+76−0
1+/**
2+ * Which g1t the app talks to. Hosted g1t is the site `https://g1t.sh` and
3+ * the API `https://api.g1t.sh`; a self-hosted g1t sets its own (SITE_URL
4+ * and API_URL, apps/api/src/addresses.rs), so the app keeps both.
5+ *
6+ * The site is where people sign in and approve the app, and where Chat's
7+ * routes are; the API is the REST API and the OAuth issuer.
8+ *
9+ * Kept free of React Native imports so `npm test` runs it under Node.
10+ */
11+
12+export type Server = {
13+ /** An origin, no trailing slash: `https://g1t.sh`. */
14+ site: string;
15+ /** An origin, no trailing slash: `https://api.g1t.sh`. */
16+ api: string;
17+};
18+
19+export const HOSTED: Server = { site: "https://g1t.sh", api: "https://api.g1t.sh" };
20+
21+/**
22+ * An origin as typed: http or https with a host, `https://` when no scheme
23+ * is given; only the origin is kept. Null when it is not a web address.
24+ * Plain http is accepted for a g1t on this network, as in development.
25+ */
26+export function parseOrigin(text: string): string | null {
27+ const trimmed = text.trim();
28+ if (!trimmed) return null;
29+ const withScheme = trimmed.includes("://") ? trimmed : `https://${trimmed}`;
30+ let url: URL;
31+ try {
32+ url = new URL(withScheme);
33+ } catch {
34+ return null;
35+ }
36+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
37+ if (!url.hostname) return null;
38+ return `${url.protocol}//${url.host}`;
39+}
40+
41+/**
42+ * The API's address for a site, when only the site was given: hosted
43+ * g1t's own, or `api.` in front of a self-hosted site's host, which is
44+ * how deploy/self-host names it.
45+ */
46+export function apiFor(site: string): string {
47+ if (site === HOSTED.site) return HOSTED.api;
48+ const url = new URL(site);
49+ return `${url.protocol}//api.${url.host}`;
50+}
51+
52+/** A server from what was typed: the site, and the API if it was given. */
53+export function parseServer(siteText: string, apiText?: string): Server | null {
54+ const site = parseOrigin(siteText);
55+ if (!site) return null;
56+ if (apiText && apiText.trim()) {
57+ const api = parseOrigin(apiText);
58+ if (!api) return null;
59+ return { site, api };
60+ }
61+ return { site, api: apiFor(site) };
62+}
63+
64+export function isHosted(server: Server): boolean {
65+ return server.site === HOSTED.site && server.api === HOSTED.api;
66+}
67+
68+/** The site's address for a path, such as a page to open in the browser. */
69+export function siteUrl(server: Server, path: string): string {
70+ return server.site + (path.startsWith("/") ? path : `/${path}`);
71+}
72+
73+/** The socket address for one of the site's live paths. */
74+export function socketUrl(server: Server, path: string): string {
75+ return siteUrl(server, path).replace(/^http/, "ws");
76+}
+164−0
1+import type { Membership, User } from "@g1t/contracts";
2+import { useQueryClient } from "@tanstack/react-query";
3+import * as SecureStore from "expo-secure-store";
4+import { type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
5+
6+import { ApiError, Client } from "./client";
7+import { type Credentials, clearCredentials, loadCredentials, loadServer, saveCredentials, saveServer } from "./credentials";
8+import { signInWithOAuth } from "./oauth";
9+import { unregisterPush } from "./push";
10+import type { Server } from "./server";
11+
12+/**
13+ * Who is signed in, to which g1t, and in which workspace. Everything that
14+ * talks to g1t reads its {@link Client} from here.
15+ */
16+
17+/** `GET /user`: the person, their workspaces, and what the token may do. */
18+export type Me = User & { token?: { token_id: string; scopes?: string[] } };
19+
20+type State =
21+ | { status: "loading"; server: Server | null }
22+ | { status: "signed-out"; server: Server }
23+ | { status: "signed-in"; server: Server; client: Client; me: Me; workspace: Membership | null };
24+
25+type SignedInState = Extract<State, { status: "signed-in" }>;
26+
27+type SessionApi = {
28+ state: State;
29+ /**
30+ * The last signed-in state, kept through signing out: the signed-in
31+ * screens render once more as they leave, and read it then.
32+ */
33+ lastSignedIn: SignedInState | null;
34+ /** Opens the site to approve the app, then signs in. */
35+ signIn: () => Promise<void>;
36+ /** Signs in with a personal access token (`g1t_…`). */
37+ signInWithToken: (token: string) => Promise<void>;
38+ signOut: () => Promise<void>;
39+ /** Points the app at another g1t; signs out of the one before. */
40+ changeServer: (server: Server) => Promise<void>;
41+ /** Switches workspace by slug. */
42+ selectWorkspace: (slug: string) => Promise<void>;
43+};
44+
45+const WORKSPACE_KEY = "g1t.workspace";
46+
47+const SessionContext = createContext<SessionApi | null>(null);
48+
49+export function SessionProvider({ children }: { children: ReactNode }) {
50+ const [state, setState] = useState<State>({ status: "loading", server: null });
51+ const queries = useQueryClient();
52+ const serverRef = useRef<Server | null>(null);
53+ const lastSignedIn = useRef<SignedInState | null>(null);
54+ if (state.status === "signed-in") lastSignedIn.current = state;
55+
56+ const signedOut = useCallback(
57+ async (server: Server) => {
58+ await clearCredentials();
59+ queries.clear();
60+ setState({ status: "signed-out", server });
61+ },
62+ [queries],
63+ );
64+
65+ /** Makes a client for credentials, reads who they are, and signs in. */
66+ const start = useCallback(
67+ async (server: Server, credentials: Credentials) => {
68+ const client = new Client({
69+ server,
70+ credentials,
71+ onCredentials: (next) => {
72+ if (next === null) void signedOut(server);
73+ },
74+ });
75+ const me = await client.api<Me>("/user");
76+ if ((me.kind ?? "user") !== "user") {
77+ throw new ApiError("forbidden", "Sign in as yourself: a workspace's or an agent's token cannot use the app.");
78+ }
79+ const saved = await SecureStore.getItemAsync(WORKSPACE_KEY).catch(() => null);
80+ const workspaces = me.workspaces ?? [];
81+ const workspace = workspaces.find((one) => one.slug === saved) ?? workspaces[0] ?? null;
82+ setState({ status: "signed-in", server, client, me, workspace });
83+ },
84+ [signedOut],
85+ );
86+
87+ useEffect(() => {
88+ void (async () => {
89+ const server = await loadServer();
90+ serverRef.current = server;
91+ const credentials = await loadCredentials();
92+ if (!credentials) return setState({ status: "signed-out", server });
93+ try {
94+ await start(server, credentials);
95+ } catch (error) {
96+ // Offline at launch keeps the credentials for next time; anything
97+ // else (revoked, expired) signs out.
98+ if (error instanceof ApiError && error.code === "offline") {
99+ setState({ status: "signed-out", server });
100+ return;
101+ }
102+ await signedOut(server);
103+ }
104+ })();
105+ }, [start, signedOut]);
106+
107+ const api = useMemo<SessionApi>(() => {
108+ const current = () => serverRef.current ?? state.server;
109+ return {
110+ state,
111+ lastSignedIn: lastSignedIn.current,
112+ signIn: async () => {
113+ const server = current();
114+ if (!server) return;
115+ const credentials = await signInWithOAuth(server);
116+ await saveCredentials(credentials);
117+ await start(server, credentials);
118+ },
119+ signInWithToken: async (token) => {
120+ const server = current();
121+ if (!server) return;
122+ const trimmed = token.trim();
123+ if (!trimmed.startsWith("g1t_")) throw new ApiError("failed", "An access token starts with g1t_.");
124+ const credentials: Credentials = { kind: "token", accessToken: trimmed };
125+ await start(server, credentials);
126+ await saveCredentials(credentials);
127+ },
128+ signOut: async () => {
129+ const server = current();
130+ // The phone stops getting notifications first, while the token still works.
131+ if (state.status === "signed-in") await unregisterPush(state.client).catch(() => undefined);
132+ if (server) await signedOut(server);
133+ },
134+ changeServer: async (server) => {
135+ await saveServer(server);
136+ serverRef.current = server;
137+ await signedOut(server);
138+ },
139+ selectWorkspace: async (slug) => {
140+ if (state.status !== "signed-in") return;
141+ const workspace = state.me.workspaces?.find((one) => one.slug.toLowerCase() === slug.toLowerCase());
142+ if (!workspace || workspace.slug === state.workspace?.slug) return;
143+ await SecureStore.setItemAsync(WORKSPACE_KEY, workspace.slug);
144+ setState({ ...state, workspace });
145+ },
146+ };
147+ }, [state, start, signedOut]);
148+
149+ return <SessionContext.Provider value={api}>{children}</SessionContext.Provider>;
150+}
151+
152+export function useSession(): SessionApi {
153+ const session = useContext(SessionContext);
154+ if (!session) throw new Error("useSession is used outside SessionProvider.");
155+ return session;
156+}
157+
158+/** The signed-in session, for screens that only show when signed in. */
159+export function useSignedIn() {
160+ const { state, lastSignedIn, ...rest } = useSession();
161+ const signedIn = state.status === "signed-in" ? state : lastSignedIn;
162+ if (!signedIn) throw new Error("This screen needs a signed-in session.");
163+ return { ...signedIn, ...rest };
164+}
+20−0
1+/** "now", "5m", "3h", "2d", or a date, for lists. */
2+export function ago(iso: string, now = Date.now()): string {
3+ const then = Date.parse(iso);
4+ if (Number.isNaN(then)) return "";
5+ const seconds = Math.max(0, Math.round((now - then) / 1000));
6+ if (seconds < 60) return "now";
7+ const minutes = Math.round(seconds / 60);
8+ if (minutes < 60) return `${minutes}m`;
9+ const hours = Math.round(minutes / 60);
10+ if (hours < 24) return `${hours}h`;
11+ const days = Math.round(hours / 24);
12+ if (days < 7) return `${days}d`;
13+ return new Date(then).toLocaleDateString(undefined, { month: "short", day: "numeric" });
14+}
15+
16+/** A message's time of day: "14:05". */
17+export function clock(iso: string): string {
18+ const date = new Date(iso);
19+ return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
20+}
+6−0
1+/**
2+ * The conversation on screen now, if any: a banner or a notification for
3+ * it is not shown, as the site skips toasts for the conversation open.
4+ * Set by the conversation and thread screens while they are in front.
5+ */
6+export const viewing: { channelId: string | null; threadRoot: string | null } = { channelId: null, threadRoot: null };
+10−0
1+import { useColorScheme } from "react-native";
2+
3+import { dark, light, type Palette } from "./tokens";
4+
5+export { fonts, radius, space, type Palette } from "./tokens";
6+
7+/** The palette for the phone's light or dark setting; dark when it has none. */
8+export function useColors(): Palette {
9+ return useColorScheme() === "light" ? light : dark;
10+}
+93−0
1+/**
2+ * g1t's colours, as packages/theme/tokens.css names them, for React
3+ * Native, which cannot read CSS variables. Keep the two in step: a colour
4+ * changed there changes here.
5+ *
6+ * Dark is g1t's default, as on the site; light follows the phone's setting.
7+ */
8+
9+export type Palette = {
10+ bg: string;
11+ surface: string;
12+ raised: string;
13+ line: string;
14+ lineStrong: string;
15+ fg: string;
16+ fgSoft: string;
17+ muted: string;
18+ faint: string;
19+ accent: string;
20+ accentDim: string;
21+ accentLow: string;
22+ /** Text on an accent-filled button. */
23+ onAccent: string;
24+ /** The rail and the bottom bar (apps/web app.css `--color-rail`). */
25+ rail: string;
26+ /** What marks the app you are in on the rail (`--color-sidebar-accent`). */
27+ railAccent: string;
28+ success: string;
29+ successLow: string;
30+ info: string;
31+ merged: string;
32+ warn: string;
33+ danger: string;
34+};
35+
36+export const dark: Palette = {
37+ bg: "#0f0f11",
38+ surface: "#161618",
39+ raised: "#1e1e21",
40+ line: "#28282c",
41+ lineStrong: "#38383e",
42+ fg: "#ededef",
43+ fgSoft: "#dcdce0",
44+ muted: "#a0a0a8",
45+ faint: "#86868e",
46+ accent: "#b6a8ff",
47+ accentDim: "#8577e0",
48+ accentLow: "#221e38",
49+ onAccent: "#0f0f11",
50+ rail: "#050507",
51+ railAccent: "#1c1c20",
52+ success: "#86efc4",
53+ successLow: "#10302a",
54+ info: "#8ab4ff",
55+ merged: "#a678f5",
56+ warn: "#ffbd8c",
57+ danger: "#ff8394",
58+};
59+
60+export const light: Palette = {
61+ bg: "#fbfbfa",
62+ surface: "#ffffff",
63+ raised: "#f1f1ef",
64+ line: "#e4e4e1",
65+ lineStrong: "#d2d2ce",
66+ fg: "#18181b",
67+ fgSoft: "#2e2e33",
68+ muted: "#5c5c66",
69+ faint: "#686871",
70+ accent: "#6b52c8",
71+ accentDim: "#9584dc",
72+ accentLow: "#ece8fb",
73+ onAccent: "#ffffff",
74+ rail: "#ffffff",
75+ railAccent: "#e1e1de",
76+ success: "#0d7a52",
77+ successLow: "#e2f4ec",
78+ info: "#2160cc",
79+ merged: "#7c3fd1",
80+ warn: "#a84f00",
81+ danger: "#c42a43",
82+};
83+
84+export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 } as const;
85+
86+export const radius = { sm: 6, md: 10, lg: 14, full: 999 } as const;
87+
88+/**
89+ * The site's faces (Hanken Grotesk, Bricolage Grotesque, IBM Plex Mono)
90+ * ship as woff2, which phones cannot load; until TTF copies are bundled
91+ * with expo-font, the system's faces stand in.
92+ */
93+export const fonts = { mono: "monospace" } as const;
+14−0
1+{
2+ "extends": "expo/tsconfig.base",
3+ "compilerOptions": {
4+ "strict": true,
5+ "skipLibCheck": true,
6+ "paths": {
7+ "@/*": ["./src/*"],
8+ "@/assets/*": ["./assets/*"]
9+ }
10+ },
11+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
12+ // Node runs the tests on the files as they are (`npm test`).
13+ "exclude": ["node_modules", "src/**/*.test.ts"]
14+}
+8−8
353353
354354 /*
355355 * The app's frame (components/shell.tsx), set on <body data-frame="app">:
356− * the page fills the window beside the rail and the sidebar; on a phone
357− * it is a rounded panel inset above the bottom bar. What sticks to the top
358− * or the bottom of a page reads these:
359− * --frame-top the gap above the page (none on a computer)
356+ * the page fills the window beside the rail and the sidebar, and on a
357+ * phone it fills the window above the bottom bar, flat, as on a computer.
358+ * What sticks to the top or the bottom of a page reads these:
359+ * --frame-top what is above the page: a phone's status bar, where it overlaps
360360 * --topbar-h that gap plus the top bar: where sticky things sit
361− * --tabbar-h what the bottom takes: on a phone, the gap under the panel and the bar
361+ * --tabbar-h what the bottom takes: on a phone, the bar and the home indicator under it
362362 * --page-h the height left for a page that fills the window (Chat)
363363 * The public frame keeps a plain header 3.5rem tall and nothing at the bottom.
364364 */
374374 }
375375 @media (max-width: 767px) {
376376 body[data-frame="app"] {
377− --frame-top: max(0.375rem, env(safe-area-inset-top));
378− /* The bottom bar: 64px tall, 8px off the edge, 6px under the panel. */
379− --tabbar-h: calc(4.875rem + env(safe-area-inset-bottom));
377+ --frame-top: env(safe-area-inset-top);
378+ /* The bottom bar: 56px tall along the bottom edge, and the home indicator's room under it. */
379+ --tabbar-h: calc(3.5rem + env(safe-area-inset-bottom));
380380 }
381381 }
382382
+18−2
6868 import { type Mentionable, type ShownMessage, agentsAmong, channelPath, dmPlaceholder, isMixed, mentionNames, mergeMessages, mixedNotice, shownHandle, shownName, timeline } from "../../lib/chat";
6969 import { codeAccessPath } from "../../lib/workspace-nav";
7070 import { conversationCache } from "./conversation-cache";
71+import { atBottom, useKeepAtBottom } from "./stick";
7172 // Reactions and the workspace's own emoji (components/emoji).
7273 import { EmojiProvider, useCustomEmoji } from "../emoji/context";
7374 import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar";
243244 useEffect(() => {
244245 if (stick.current) toBottom(true);
245246 }, [messages.length, typing.size, toBottom]);
247+ // And while the list or what is in it changes size: the keyboard opening on a phone, a card laying out.
248+ useKeepAtBottom(scroller, stick);
246249
247250 // Read up to the newest message whenever it is in view.
248251 const lastRead = useRef<string | null>(null);
648651 ref={scroller}
649652 onScroll={(event) => {
650653 const element = event.currentTarget;
651− stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 120;
654+ stick.current = atBottom(element);
652655 }}
653656 className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
654657 >
15791582 draftKey: string;
15801583 }) {
15811584 const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]);
1585+ // The newest reply stays in view as replies arrive and as the keyboard opens, unless the reader scrolled up.
1586+ const scroller = useRef<HTMLDivElement>(null);
1587+ const stick = useRef(true);
1588+ useKeepAtBottom(scroller, stick);
15821589 return (
15831590 <>
1584− <div className="min-h-0 grow overflow-y-auto px-1 py-3 [scrollbar-width:thin]">
1591+ <div
1592+ ref={scroller}
1593+ onScroll={(event) => {
1594+ stick.current = atBottom(event.currentTarget);
1595+ }}
1596+ className="min-h-0 grow overflow-y-auto overscroll-contain px-1 py-3 [scrollbar-width:thin]"
1597+ >
1598+ {/* One box for what scrolls, so its growing is seen (./stick.ts). */}
1599+ <div>
15851600 {root ? (
15861601 <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread />
15871602 ) : (
16061621 />
16071622 ) : null,
16081623 )}
1624+ </div>
16091625 </div>
16101626 {joined && (
16111627 <div className="shrink-0 p-3">
+32−0
1+import { type RefObject, useEffect, useRef } from "react";
2+
3+/**
4+ * Keeps a conversation's newest message in view while the reader is at
5+ * the bottom: when the list itself changes size (the keyboard opening on
6+ * a phone shrinks it, a window resized) and when what is in it does (a
7+ * card, an image or a code block laying out after it arrived). Without
8+ * this, a list that gets shorter keeps its distance from the top, and the
9+ * newest messages slide out of sight under the composer.
10+ *
11+ * `stick` says whether the reader is at the bottom (within 120px), as the
12+ * list's `onScroll` keeps it (`atBottom`); a reader scrolled up into the
13+ * history is left where they are.
14+ */
15+export function useKeepAtBottom(scroller: RefObject<HTMLElement | null>, stick: RefObject<boolean>) {
16+ useEffect(() => {
17+ const element = scroller.current;
18+ if (!element || typeof ResizeObserver === "undefined") return;
19+ const keep = () => {
20+ if (stick.current) element.scrollTop = element.scrollHeight;
21+ };
22+ const observer = new ResizeObserver(keep);
23+ observer.observe(element);
24+ for (const child of Array.from(element.children)) observer.observe(child);
25+ return () => observer.disconnect();
26+ }, [scroller, stick]);
27+}
28+
29+/** Whether a list is scrolled to (within 120px of) its bottom. */
30+export function atBottom(element: HTMLElement): boolean {
31+ return element.scrollHeight - element.scrollTop - element.clientHeight < 120;
32+}
+26−19
120120 type Tab = { key: ModeKey; label: string; badge?: { count: number; loud: boolean } };
121121
122122 /**
123− * The bottom bar of a phone, floating 8px from the edges: Home, Chat,
123+ * The bottom bar of a phone, flat along the bottom edge under a rule, as
124+ * the rail stands flat beside the page on a computer: Home, Chat,
124125 * Notifications, Agents and Code (for a member with Code access), each
125− * with what is unread, and More. It steps aside while the keyboard is up
126+ * with what is unread, and More. The tab you are in has the rail's own
127+ * mark: its icon on the sidebar's accent. It steps aside while the keyboard is up
126128 * and inside a conversation. The tab you are in, tapped again, opens its
127129 * mode's menu (its sidebar).
128130 */
160162 <>
161163 <nav
162164 aria-label="Apps"
163− className="fixed right-2 bottom-[calc(0.5rem+env(safe-area-inset-bottom))] left-2 z-40 h-16 rounded-2xl border border-line bg-rail px-1 shadow-[0_1px_0_rgba(255,255,255,0.03)_inset,0_8px_24px_rgba(0,0,0,0.35)] md:hidden in-data-[keyboard=open]:hidden"
165+ className="fixed inset-x-0 bottom-0 z-40 h-(--tabbar-h) border-t border-line bg-rail pb-[env(safe-area-inset-bottom)] md:hidden in-data-[keyboard=open]:hidden"
164166 >
165167 <ul className="grid h-full" style={{ gridTemplateColumns: `repeat(${tabs.length + 1}, minmax(0, 1fr))` }}>
166168 {tabs.map((tab) => {
183185 }}
184186 className={BAR_ITEM}
185187 >
186− {current && <TopBar />}
187188 <BarIcon current={current}>
188189 {appIcon(tab.key as BuiltinApp, 20)}
189190 {tab.badge && <CountBadge count={count} loud={tab.badge.loud} />}
190191 </BarIcon>
191− <span className={`w-full truncate text-[0.625rem] leading-tight font-medium max-[380px]:text-[0.5625rem] max-[380px]:tracking-[-0.02em] ${current ? "text-fg" : "text-faint"}`}>{tab.label}</span>
192+ <BarLabel current={current}>{tab.label}</BarLabel>
192193 </Link>
193194 </li>
194195 );
195196 })}
196197 <li className="min-w-0">
197198 <button type="button" aria-expanded={more} aria-haspopup="dialog" onClick={() => setMore(true)} className={BAR_ITEM}>
198− {(more || !inTabs) && <TopBar />}
199199 <BarIcon current={more || !inTabs}>
200200 <Ellipsis size={20} />
201201 </BarIcon>
202− <span className={`w-full truncate text-[0.625rem] leading-tight font-medium ${more || !inTabs ? "text-fg" : "text-faint"}`}>More</span>
202+ <BarLabel current={more || !inTabs}>More</BarLabel>
203203 </button>
204204 </li>
205205 </ul>
209209 );
210210 }
211211
212−const BAR_ITEM = "relative flex h-full w-full flex-col items-center justify-center gap-0.5 rounded-xl outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent active:bg-raised/60";
212+const BAR_ITEM = "group relative flex h-full w-full flex-col items-center justify-center gap-1 outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset";
213213
214−/** The bar on the bottom bar's top edge, over the current tab. */
215−function TopBar() {
216− return <span aria-hidden="true" className="absolute -top-px left-1/2 h-[3px] w-[22px] -translate-x-1/2 rounded-b-[3px] bg-fg" />;
214+/** A tab's icon, marked as the rail marks the app you are in (ui/sidebar.tsx, `size: icon`). */
215+function BarIcon({ current, children }: { current: boolean; children: ReactNode }) {
216+ return (
217+ <span
218+ className={`relative flex h-7 w-11 items-center justify-center rounded-[10px] transition-colors group-active:bg-sidebar-accent/60 ${current ? "bg-sidebar-accent text-fg" : "text-muted"}`}
219+ >
220+ {children}
221+ </span>
222+ );
217223 }
218224
219−function BarIcon({ current, children }: { current: boolean; children: ReactNode }) {
225+/** A tab's name, centred under its icon; a long one gives way rather than crowding its neighbours. */
226+function BarLabel({ current, children }: { current: boolean; children: ReactNode }) {
220227 return (
221− <span className={`relative flex size-8 items-center justify-center rounded-[10px] transition-colors ${current ? "bg-raised text-fg shadow-[inset_0_0_0_1px_var(--color-line-strong)]" : "text-muted"}`}>
228+ <span className={`block w-full truncate px-0.5 text-center text-[0.6875rem] leading-none font-medium max-[380px]:text-[0.625rem] ${current ? "text-fg" : "text-faint"}`}>
222229 {children}
223230 </span>
224231 );
228235 function MoreTile({ to, icon, label, onClick, disabled, note }: { to?: string; icon: ReactNode; label: string; onClick?: () => void; disabled?: boolean; note?: string }) {
229236 const inner = (
230237 <>
231− <span className={`flex size-11 items-center justify-center rounded-xl ${disabled ? "bg-raised/60 text-faint" : "bg-raised text-fg"}`}>{icon}</span>
238+ <span className={`flex size-11 items-center justify-center rounded-[10px] ${disabled ? "bg-raised/60 text-faint" : "bg-raised text-fg"}`}>{icon}</span>
232239 <span className={`line-clamp-2 w-full text-xs leading-tight max-[380px]:text-[0.6875rem] ${disabled ? "text-faint" : "text-fg-soft"}`}>{label}</span>
233240 {note && <span className="-mt-0.5 rounded-full bg-line px-1.5 text-[0.625rem] font-medium text-muted">{note}</span>}
234241 </>
235242 );
236− const className = "flex min-w-0 flex-col items-center gap-1.5 rounded-xl px-1 py-2.5 text-center outline-none focus-visible:ring-2 focus-visible:ring-accent active:bg-raised/60";
243+ const className = "flex min-w-0 flex-col items-center gap-1.5 rounded-md px-1 py-2.5 text-center outline-none focus-visible:ring-2 focus-visible:ring-accent active:bg-raised/60";
237244 if (to) {
238245 return (
239246 <Link to={to} className={className}>
249256 }
250257
251258 /**
252− * More, from the bottom bar: a rounded panel just above it, with g1t's
259+ * More, from the bottom bar: a flat panel standing on it, with g1t's
253260 * mark at its top and a grid of big tiles: all apps, the Marketplace,
254261 * your pinned apps, Artifacts, People, Workspace, and "You and
255262 * help", which opens your account's sheet.
266273 <Primitive.Content
267274 aria-describedby={undefined}
268275 onOpenAutoFocus={(event) => event.preventDefault()}
269− className="fixed right-2 bottom-[calc(4.875rem+env(safe-area-inset-bottom))] left-2 z-50 max-h-[calc(100dvh-7rem)] overflow-y-auto rounded-[18px] border border-line-strong bg-rail px-3 pt-2 pb-3.5 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out md:hidden"
276+ className="fixed inset-x-0 bottom-(--tabbar-h) z-50 max-h-[calc(100dvh-var(--tabbar-h)-3.5rem)] overflow-y-auto border-t border-line bg-rail px-3 pt-2 pb-3.5 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out md:hidden"
270277 >
271278 <div aria-hidden="true" className="mx-auto mt-0.5 mb-2 h-1 w-9 rounded-full bg-line-strong" />
272279 <div className="mb-2 flex items-center gap-2 px-1">
342349 aria-describedby={undefined}
343350 onOpenAutoFocus={(event) => event.preventDefault()}
344351 style={{ transform: drag ? `translateY(${drag}px)` : undefined, transition: from.current == null ? "transform 0.2s ease-out" : "none" }}
345− className="fixed inset-x-0 bottom-0 z-50 flex max-h-[88dvh] flex-col rounded-t-2xl border-t border-line-strong bg-surface pb-[env(safe-area-inset-bottom)] text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-[sheet-in-bottom_0.24s_cubic-bezier(0.16,1,0.3,1)]"
352+ className="fixed inset-x-0 bottom-0 z-50 flex max-h-[88dvh] flex-col border-t border-line bg-surface pb-[env(safe-area-inset-bottom)] text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-[sheet-in-bottom_0.24s_cubic-bezier(0.16,1,0.3,1)]"
346353 >
347354 <div {...handlers} className="flex shrink-0 cursor-grab touch-none justify-center pt-2.5 pb-2" aria-hidden="true">
348355 <span className="h-1.5 w-10 rounded-full bg-line-strong" />
357364
358365 /** A row in a bottom sheet: 48px tall, an icon, words, and what sits at its end. */
359366 export function SheetRow({ to, href, icon, children, end, onClick }: { to?: string; href?: string; icon: ReactNode; children: ReactNode; end?: ReactNode; onClick?: () => void }) {
360− const className = "flex h-12 w-full items-center gap-3 rounded-xl px-3 text-left text-[0.9375rem] text-fg-soft transition-colors active:bg-raised";
367+ const className = "flex h-12 w-full items-center gap-3 rounded-md px-3 text-left text-[0.9375rem] text-fg-soft transition-colors active:bg-raised";
361368 const inner = (
362369 <>
363370 <span className="flex w-6 shrink-0 justify-center text-muted [&_svg]:size-5">{icon}</span>
+24−25
288288 children,
289289 }: {
290290 title: string;
291− /** A small button at the label's end: what the group makes (a `SidebarGroupAction`). */
291+ /** A small button at the label's end, in its row: what the group makes (a `SidebarGroupAction` with `relative top-auto right-auto`). */
292292 action?: ReactNode;
293293 className?: string;
294294 children: ReactNode;
295295 }) {
296296 return (
297297 <SidebarGroup className={className}>
298− <SidebarGroupLabel asChild>
299− <h2>{title}</h2>
300− </SidebarGroupLabel>
301− {action}
298+ <div className="flex items-center justify-between gap-2 pr-1">
299+ <SidebarGroupLabel asChild>
300+ <h2>{title}</h2>
301+ </SidebarGroupLabel>
302+ {action}
303+ </div>
302304 <SidebarGroupContent>
303305 <SidebarMenu>{children}</SidebarMenu>
304306 </SidebarGroupContent>
688690 );
689691 return (
690692 <SidebarGroup>
691− <SidebarGroupLabel asChild>
692− <h2>Projects</h2>
693− </SidebarGroupLabel>
694− <Hint label="New project">
695− <SidebarGroupAction asChild>
696− <Link to="/new" aria-label="New project">
697− <Plus />
698− </Link>
699− </SidebarGroupAction>
700− </Hint>
693+ {/* The label and its button share a row, so the button stays level with the words at any text size. */}
694+ <div className="flex items-center justify-between gap-2 pr-1">
695+ <SidebarGroupLabel asChild>
696+ <h2>Projects</h2>
697+ </SidebarGroupLabel>
698+ <Hint label="New project">
699+ <SidebarGroupAction asChild className="relative top-auto right-auto">
700+ <Link to="/new" aria-label="New project">
701+ <Plus />
702+ </Link>
703+ </SidebarGroupAction>
704+ </Hint>
705+ </div>
701706 <SidebarGroupContent>
702707 {/* Only there once something is pinned: each row below has its own pin. */}
703708 {pinned.length > 0 && (
19411946 * filling the rest of the window, with its top bar across the top and a
19421947 * line under it: the sidebar's toggle, where you are, search, Ask g1t and
19431948 * Create new. On a phone the rail is a bar along the bottom and the page
1944− * a rounded panel above it. Only for someone signed in; without a workspace yet, there
1949+ * fills the window above it, flat as well. Only for someone signed in; without a workspace yet, there
19451950 * is no rail. The rail and the sidebar are drawn from the sidebar kit
19461951 * (components/ui/sidebar.tsx): one provider holds whether the sidebar is
19471952 * open on a computer and whether the drawer is.
20892094 </SidebarMobile>
20902095 )}
20912096
2092− <div className={`min-h-dvh pt-(--frame-top) pb-(--tabbar-h) max-md:px-1.5 ${pad}`}>
2097+ <div className={`min-h-dvh pt-(--frame-top) pb-(--tabbar-h) ${pad}`}>
20932098 {/* The top bar always has its line, so a page's own header (data-page-head: a conversation's, a project's,
20942099 a sticky one under an open artifact) sits under it as its own band. */}
2095− <div className="flex min-h-[calc(100dvh-var(--frame-top)-var(--tabbar-h))] min-w-0 flex-col bg-bg max-md:rounded-[14px]">
2100+ <div className="flex min-h-[calc(100dvh-var(--frame-top)-var(--tabbar-h))] min-w-0 flex-col bg-bg">
20962101 <header
2097− className={`sticky top-(--frame-top) z-30 flex h-14 items-center gap-1 border-b border-line bg-bg pr-2 pl-2 max-md:rounded-t-[14px] sm:pr-3 ${inline ? "lg:pl-3.5" : ""} ${conversation ? "max-md:hidden" : ""}`}
2102+ className={`sticky top-(--frame-top) z-30 flex h-14 items-center gap-1 border-b border-line bg-bg pr-2 pl-2 sm:pr-3 ${inline ? "lg:pl-3.5" : ""} ${conversation ? "max-md:hidden" : ""}`}
20982103 >
20992104 {panel && (
21002105 <SidebarTrigger
21422147 </main>
21432148 </div>
21442149 </div>
2145− {/* On a phone the page is a rounded panel above the bottom bar: a frame over it, with the background beyond,
2146− so what scrolls stays inside. A phone's conversation, full screen over everything, goes without. */}
2147− <div
2148− aria-hidden="true"
2149− className={`pointer-events-none fixed top-(--frame-top) right-1.5 bottom-(--tabbar-h) left-1.5 z-[35] rounded-[14px] shadow-[0_0_0_1px_var(--color-line),0_0_0_100vmax_var(--color-shell)] md:hidden ${conversation ? "max-md:hidden" : ""}`}
2150− />
21512150 {ws && <BottomBar user={user} workspace={ws} pins={shell.pins ?? []} unread={unread} onReselect={panel ? () => setDrawer(true) : undefined} />}
21522151 <CommandPalette open={palette} onOpenChange={setPalette} commands={commands} repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null} />
21532152 </SidebarProvider>
+3−2
160160 event.preventDefault();
161161 (event.currentTarget as HTMLElement | null)?.focus();
162162 }}
163− className={cn("group/sidebar w-[min(var(--sidebar-width),86vw)] border-sidebar-border bg-sidebar px-0 text-fg", className)}
163+ // A phone's drawer takes most of the screen: names read whole and rows reach a thumb, with the page still showing beside it.
164+ className={cn("group/sidebar w-[min(22rem,86vw)] border-sidebar-border bg-sidebar px-0 text-fg", className)}
164165 {...props}
165166 >
166167 <SheetTitle className="sr-only">{title}</SheetTitle>
235236 }
236237
237238 export function SidebarSeparator({ className, ...props }: ComponentProps<typeof Separator>) {
238− return <Separator data-slot="sidebar-separator" className={cn("mx-2 my-2 w-auto bg-sidebar-border", className)} {...props} />;
239+ return <Separator data-slot="sidebar-separator" className={cn("mx-2 my-2 bg-sidebar-border data-[orientation=horizontal]:w-auto", className)} {...props} />;
239240 }
240241
241242 /** What scrolls: the groups, between the header and the footer. */
+4−4
55 import { getViewer, roleIn } from "./session.server";
66 import { identity } from "./services.server";
77 import { issueTicket, socketPath, ticketViewer } from "./socket-ticket";
8−import { bearerToken, websiteUser } from "./website-token";
8+import { bearerToken, socketUser } from "./website-token";
99
1010 let isolateSecret: string | null = null;
1111
3535 export async function socketTicketFor(context: Parameters<typeof getViewer>[0], request: Request, path: string): Promise<{ ticket: string; expires_at: string } | null> {
3636 const viewer = getViewer(context);
3737 const token = bearerToken(request);
38− if (!viewer || !token || !viewer.token?.website) return null;
38+ if (!viewer || !token || !viewer.token) return null;
3939 const socket = socketPath(path);
40− if (!socket || (socket.workspace && !roleIn(viewer, socket.workspace))) return null;
40+ if (!socket || !socketUser(viewer, socket.path) || (socket.workspace && !roleIn(viewer, socket.workspace))) return null;
4141 return issueTicket(ticketSecret(), { token, userId: viewer.id, path: socket.path });
4242 }
4343
4949 export async function socketViewer(context: Parameters<typeof getViewer>[0], request: Request): Promise<User | null> {
5050 const viewer = getViewer(context);
5151 if (viewer) return viewer;
52− return ticketViewer(request, ticketSecret(), async (token) => websiteUser(await identity.userForAccessToken(token)));
52+ return ticketViewer(request, ticketSecret(), async (token, path) => socketUser(await identity.userForAccessToken(token), path));
5353 }
+20−1
55
66 import { liveAddress, offerTicket, openLive, setLiveViaToken, takeOfferedTicket } from "./live-socket.ts";
77 import { TICKET_PARAM, TICKET_ROUTE, TICKET_SECONDS, issueTicket, openTicket, socketPath, ticketViewer } from "./socket-ticket.ts";
8−import { tokenVerdict, websiteUser } from "./website-token.ts";
8+import { socketUser, tokenVerdict, websiteUser } from "./website-token.ts";
99
1010 const SECRET = "site-secret";
1111 const NOW = Date.UTC(2026, 9, 9, 12, 0, 0);
178178 globalThis.fetch = was.fetch;
179179 }
180180 });
181+
182+test("a ticket for a chat-scoped token opens Chat's socket and the feed, checked again as it opens", async () => {
183+ const app: User = { ...ada, token: { token_id: "tok_app", scopes: ["chat:read"] } };
184+ const tokens: Record<string, Viewer> = { g1t_app: app };
185+ // As the site checks it: identity's answer narrowed by `socketUser` for the socket's path.
186+ const bySocket = async (token: string, path: string) => socketUser(tokens[token] ?? null, path);
187+ for (const path of [CHAT, "/-/live"]) {
188+ const { ticket } = await issueTicket(SECRET, { token: "g1t_app", userId: "usr_ada", path }, NOW);
189+ assert.equal((await ticketViewer(upgrade(path, ticket), SECRET, bySocket, NOW))?.id, "usr_ada", path);
190+ }
191+ // An artifact's socket is the website's alone, even with a genuine ticket.
192+ const artifacts = "/acme/-/artifacts/live";
193+ const { ticket } = await issueTicket(SECRET, { token: "g1t_app", userId: "usr_ada", path: artifacts }, NOW);
194+ assert.equal(await ticketViewer(upgrade(artifacts, ticket), SECRET, bySocket, NOW), null);
195+ // Chat taken away since the ticket was made: the socket opens nothing.
196+ const chat = await issueTicket(SECRET, { token: "g1t_app", userId: "usr_ada", path: CHAT }, NOW);
197+ tokens.g1t_app = { ...app, token: { token_id: "tok_app", scopes: ["notifications:read"] } };
198+ assert.equal(await ticketViewer(upgrade(CHAT, chat.ticket), SECRET, bySocket, NOW), null);
199+});
+5−4
1919 * - holds the token itself, encrypted and authenticated (AES-GCM) under a
2020 * key only the site has, so the upgrade checks the token exactly as a
2121 * page request does: deleted, expired, revoked by a workspace or with
22− * "Use the website as you" turned off, it opens nothing, even inside the
22+ * "Use the website as you" turned off (or, for a token let in by its
23+ * `chat:read` scope, without it), it opens nothing, even inside the
2324 * ticket's minute;
2425 * - is never stored, logged or passed on: the socket handlers build the
2526 * service's address afresh, without it.
165166 /**
166167 * The person a socket's ticket signs in, checked as a page request with
167168 * the token would be: `lookup` is identity's `user_for_access_token`
168− * narrowed by lib/website-token.ts's `websiteUser`.
169+ * narrowed by lib/website-token.ts's `socketUser` for the socket's path.
169170 * Null for anything but a WebSocket upgrade to the socket the ticket was
170171 * made for, and for a ticket that is not genuine, has expired, or whose
171172 * token no longer may use the website.
173174 export async function ticketViewer(
174175 request: Request,
175176 secret: string,
176− lookup: (token: string) => Promise<User | null>,
177+ lookup: (token: string, path: string) => Promise<User | null>,
177178 nowMs = Date.now(),
178179 ): Promise<User | null> {
179180 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return null;
184185 if (!socket) return null;
185186 const opened = await openTicket(secret, ticket, socket.path, nowMs);
186187 if (!opened || !opened.token.startsWith("g1t_")) return null;
187− const user = await lookup(opened.token);
188+ const user = await lookup(opened.token, socket.path);
188189 return user && user.id === opened.userId ? user : null;
189190 }
+104−0
1111 bearerToken,
1212 isNeedsSignIn,
1313 needsRealSignIn,
14+ scopedRoute,
15+ scopedUser,
16+ socketUser,
1417 tokenVerdict,
1518 websiteUser,
1619 } from "./website-token.ts";
169172 assert.ok(!crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "https://g1t.sh" })));
170173 assert.ok(!crossOrigin(post(bearer("g1t_web"))), "automation that sends no Origin is not another site");
171174 });
175+
176+/** A person's token without the website permission, as an OAuth sign-in (the phone app) or a personal token has it. */
177+const scoped = (scopes: string[] | null, more: Partial<NonNullable<User["token"]>> = {}): User => ({
178+ ...ada,
179+ token: { token_id: "tok_app", scopes, ...more },
180+});
181+
182+const scopedTokens = lookup({
183+ g1t_chat_read: scoped(["chat:read", "notifications:read"]),
184+ g1t_chat_write: scoped(["chat:write", "notifications:write"]),
185+ g1t_no_chat: scoped(["repo:read", "issues:write", "notifications:read"]),
186+ g1t_full: scoped(null),
187+ g1t_legacy: scoped(null, { legacy: true }),
188+ g1t_web: ada,
189+ g1t_workspace: { ...ada, id: "wsp_1", username: "acme", kind: "workspace", token: { token_id: "tok_ws", scopes: ["chat:write"] } },
190+ g1t_job: scoped(["chat:write"], { job: { run_id: "run_1", job_id: "job_1" } }),
191+ g1t_deploy: scoped(["chat:write"], { deploy_key: "key_1" }),
192+ g1t_agent: { ...scoped(["chat:write"]), kind: "agent" },
193+});
194+
195+const verdictOf = async (token: string, path: string, method = "GET") =>
196+ (await tokenVerdict(request(path, { method, headers: bearer(token), body: method === "GET" ? undefined : JSON.stringify({ intent: "post" }) }), scopedTokens)).kind;
197+
198+test("a token with chat:read reads Chat's JSON without the website permission, and sends nothing", async () => {
199+ for (const path of ["/acme/-/chat/api", "/acme/-/chat/api?channel=ch_1&thread=m_1", "/Acme//-/chat/api/", "/acme/-/chat/person/bob"]) {
200+ assert.equal(await verdictOf("g1t_chat_read", path), "signed-in", path);
201+ }
202+ assert.equal(await verdictOf("g1t_chat_read", "/acme/-/chat/api", "POST"), "refused");
203+ assert.equal(await verdictOf("g1t_chat_read", "/acme/-/chat/person/bob", "POST"), "refused");
204+});
205+
206+test("a token with chat:write sends to Chat's JSON, and reads it too", async () => {
207+ assert.equal(await verdictOf("g1t_chat_write", "/acme/-/chat/api", "POST"), "signed-in");
208+ assert.equal(await verdictOf("g1t_chat_write", "/acme/-/chat/api"), "signed-in");
209+ assert.equal(await verdictOf("g1t_chat_write", "/-/live/ticket?path=%2Facme%2F-%2Fchat%2Flive"), "signed-in");
210+});
211+
212+test("a token without a chat scope, or not a person's own, reaches none of Chat", async () => {
213+ for (const token of ["g1t_no_chat", "g1t_workspace", "g1t_job", "g1t_deploy", "g1t_agent"]) {
214+ assert.equal(await verdictOf(token, "/acme/-/chat/api"), "signed-out", token);
215+ assert.equal(await verdictOf(token, "/acme/-/chat/api", "POST"), "refused", token);
216+ assert.equal(await verdictOf(token, "/-/live/ticket?path=%2F-%2Flive"), "signed-out", token);
217+ }
218+});
219+
220+test("a chat-scoped token opens nothing else on the site: pages are signed out, data and posts are a 401", async () => {
221+ for (const token of ["g1t_chat_write", "g1t_full"]) {
222+ for (const path of ["/", "/acme", "/acme/-/chat", "/acme/-/chat/general", "/acme/rocket", "/settings/profile", "/acme/-/chat/live", "/-/live"]) {
223+ assert.deepEqual(await tokenVerdict(request(path, { headers: bearer(token) }), scopedTokens), { kind: "signed-out" }, `${token} ${path}`);
224+ }
225+ // A client navigation's data runs the loaders around the route as well, so it is not Chat's JSON.
226+ for (const path of ["/acme/-/chat/api.data", "/acme/-/chat/person/bob.data", "/acme/-/chat.data", "/-/notify.data", "/-/live/ticket.data", "/_root.data"]) {
227+ assert.deepEqual(await tokenVerdict(request(path, { headers: bearer(token) }), scopedTokens), { kind: "refused", status: 401, body: TOKEN_REFUSED }, `${token} ${path}`);
228+ }
229+ for (const path of ["/acme/rocket/issues/new", "/acme/-/chat/general", "/-/live/ticket", "/acme/-/chat/person/bob", "/-/-/chat/api"]) {
230+ assert.equal(await verdictOf(token, path, "POST"), "refused", `${token} ${path}`);
231+ }
232+ assert.equal(await verdictOf(token, "/acme/-/chat/api", "DELETE"), "refused");
233+ }
234+ assert.equal(scopedRoute("/-/-/chat/api", "GET"), null, "`-` is no workspace");
235+ assert.equal(scopedRoute("/acme/web/-/chat/api", "GET"), null);
236+ assert.equal(scopedRoute("/acme/-/chat/person/bob/x", "GET"), null);
237+});
238+
239+test("push registration and notification settings take the notifications scopes", async () => {
240+ assert.equal(await verdictOf("g1t_chat_read", "/-/notify"), "signed-in");
241+ assert.equal(await verdictOf("g1t_chat_read", "/-/notify", "POST"), "refused");
242+ assert.equal(await verdictOf("g1t_chat_write", "/-/notify", "POST"), "signed-in");
243+ assert.equal(await verdictOf("g1t_chat_write", "/-/notify"), "signed-in", "write includes read");
244+ assert.equal(await verdictOf("g1t_no_chat", "/-/notify"), "signed-in", "notifications:read alone is enough");
245+ assert.equal(await verdictOf("g1t_no_chat", "/-/notify", "POST"), "refused");
246+ assert.equal(scopedRoute("/-/notify", "POST"), "notifications:write");
247+});
248+
249+test("full access, legacy or not, reaches Chat only with the website permission; the website permission keeps opening everything", async () => {
250+ for (const token of ["g1t_full", "g1t_legacy"]) {
251+ assert.equal(await verdictOf(token, "/acme/-/chat/api", "GET"), "signed-out", token);
252+ assert.equal(await verdictOf(token, "/acme/-/chat/api", "POST"), "refused", token);
253+ assert.equal(await verdictOf(token, "/-/notify", "POST"), "refused", token);
254+ assert.equal(socketUser(scoped(null), "/-/live"), null, token);
255+ }
256+ // A website token with no chat scope is the website's as before, Chat included.
257+ assert.equal(await verdictOf("g1t_web", "/acme/-/chat/api", "POST"), "signed-in");
258+ assert.equal(await verdictOf("g1t_web", "/acme/rocket"), "signed-in");
259+ assert.equal(scopedUser(scoped(["chat:read"]), "/acme/-/chat/api", "GET")?.username, "ada");
260+ assert.equal(scopedUser({ ...ada, token: undefined }, "/acme/-/chat/api", "GET"), null, "a session is not a token");
261+ assert.equal(scopedUser(scoped(["chat:bogus", "chat"]), "/acme/-/chat/api", "GET"), null);
262+});
263+
264+test("a chat-scoped token opens Chat's socket and the feed, never an artifact's", () => {
265+ const reader = scoped(["chat:read"]);
266+ assert.equal(socketUser(reader, "/acme/-/chat/live")?.username, "ada");
267+ assert.equal(socketUser(reader, "/-/live")?.username, "ada");
268+ assert.equal(socketUser(reader, "/acme/-/artifacts/live"), null);
269+ assert.equal(socketUser(scoped(null), "/acme/-/chat/live"), null, "full access, without the website permission");
270+ assert.equal(socketUser(scoped(["notifications:write"]), "/-/live"), null);
271+ assert.equal(socketUser(scoped(["chat:read"], { job: { run_id: "run_1", job_id: "job_1" } }), "/-/live"), null);
272+ assert.equal(socketUser({ ...ada, token: undefined }, "/-/live"), null);
273+ // The website permission opens every socket, as before.
274+ assert.equal(socketUser(ada, "/acme/-/artifacts/live")?.username, "ada");
275+});
+107−9
1919 * authentication, passwords, email addresses, SSH and signing keys,
2020 * applications, deleting the account or a workspace, giving a workspace
2121 * away, and payment methods ({@link needsRealSignIn}).
22+ * - A person's own token without "Use the website as you" is still
23+ * accepted on a few JSON routes and live sockets, when it holds their
24+ * scope: Chat's (`chat:read`, `chat:write`) and the person's push and
25+ * notification settings (`notifications:read`, `notifications:write`).
26+ * That is how an application signed in with OAuth, such as the phone
27+ * app, reaches Chat, which has no API of its own ({@link scopedRoute}).
28+ * Only scopes given by name count, not full access. Nowhere else does
29+ * such a token sign in.
2230 * - A token that is not accepted is no one: a page loads signed out, and a
2331 * data request or form post is refused with a 401 that says why.
2432 */
2533
2634 import type { User, Viewer } from "@g1t/contracts";
35+import { type Scope, tokenAllows } from "@g1t/contracts/scopes";
2736
2837 /**
2938 * The page a request is for, as routes match it: decoded, any case, no
3140 * page (as lib/confirm-gate.ts's `pageOf`).
3241 */
3342 function pageOf(pathname: string): string {
34− let path = pathname;
35− try {
36− path = decodeURIComponent(path);
37− } catch {
38− // Left as it came: routes cannot match a malformed escape either.
39− }
40− path = path.toLowerCase().replace(/\/{2,}/g, "/");
43+ let path = decodedPath(pathname);
4144 if (path.endsWith(".data")) {
4245 path = path.slice(0, -".data".length);
4346 if (path === "/_root") path = "/";
4649 return path || "/";
4750 }
4851
52+/** A path decoded, in any case, without doubled slashes. */
53+function decodedPath(pathname: string): string {
54+ let path = pathname;
55+ try {
56+ path = decodeURIComponent(path);
57+ } catch {
58+ // Left as it came: routes cannot match a malformed escape either.
59+ }
60+ return path.toLowerCase().replace(/\/{2,}/g, "/");
61+}
62+
4963 /** Where the docs explain it. */
5064 export const WEBSITE_TOKEN_DOCS = "https://docs.g1t.sh/guides/authentication/#use-a-token-on-the-website";
5165
6680 * permission. Null otherwise.
6781 */
6882 export function websiteUser(viewer: Viewer): User | null {
83+ const user = personsToken(viewer);
84+ return user?.token?.website ? user : null;
85+}
86+
87+/**
88+ * A person resolved from their own token: not a workspace, an agent or
89+ * someone acting through one, a workflow job or a deploy key. Null
90+ * otherwise, and for a session.
91+ */
92+function personsToken(viewer: Viewer): User | null {
6993 if (!viewer || (viewer.kind ?? "user") !== "user" || viewer.acting) return null;
7094 const token = viewer.token;
71− if (!token?.website || token.job || token.deploy_key) return null;
95+ if (!token || token.job || token.deploy_key) return null;
7296 return viewer;
7397 }
7498
99+/** A workspace in a path: any segment but `-`, which is no workspace's name. */
100+const WORKSPACE = "(?!-/)[^/]+";
101+
102+/**
103+ * The routes a person's token reaches by its scopes, without the website
104+ * permission: each path (decoded, any case, no doubled or trailing
105+ * slashes), the method, and the scope it needs. JSON and sockets only,
106+ * never a page: a client navigation's `.data` is not one of them, since it
107+ * would run the loaders of the pages around the route as well.
108+ */
109+const SCOPED_ROUTES: { path: RegExp; method: "GET" | "POST"; needs: Scope }[] = [
110+ // Chat's JSON: the sidebar, messages, threads, emoji, browsing channels.
111+ { path: new RegExp(`^/${WORKSPACE}/-/chat/api$`), method: "GET", needs: "chat:read" },
112+ // Sending, editing and deleting, reactions, read marks, cards' actions.
113+ { path: new RegExp(`^/${WORKSPACE}/-/chat/api$`), method: "POST", needs: "chat:write" },
114+ // A person's card in Chat.
115+ { path: new RegExp(`^/${WORKSPACE}/-/chat/person/[^/]+$`), method: "GET", needs: "chat:read" },
116+ // Tickets for Chat's live socket and the person's feed (lib/socket-ticket.ts).
117+ { path: /^\/-\/live\/ticket$/, method: "GET", needs: "chat:read" },
118+ // Push registration and notification settings (routes/notify/api.ts).
119+ { path: /^\/-\/notify$/, method: "GET", needs: "notifications:read" },
120+ { path: /^\/-\/notify$/, method: "POST", needs: "notifications:write" },
121+];
122+
123+/**
124+ * The scope a token needs to reach this request without the website
125+ * permission, or null when such a token reaches nothing here.
126+ */
127+export function scopedRoute(pathname: string, method: string): Scope | null {
128+ let path = decodedPath(pathname);
129+ if (path.length > 1) path = path.replace(/\/+$/, "");
130+ if (path.endsWith(".data")) return null;
131+ const wanted = method.toUpperCase();
132+ return SCOPED_ROUTES.find((route) => route.method === wanted && route.path.test(path))?.needs ?? null;
133+}
134+
135+/**
136+ * The person a token resolved to when it may make this request by its
137+ * scopes alone ({@link scopedRoute}): their own token, holding the scope
138+ * the route needs, by name (see {@link grants}). Null otherwise.
139+ */
140+export function scopedUser(viewer: Viewer, pathname: string, method: string): User | null {
141+ const needs = scopedRoute(pathname, method);
142+ const user = needs ? personsToken(viewer) : null;
143+ return user && needs && grants(user.token?.scopes, needs) ? user : null;
144+}
145+
146+/**
147+ * Whether a token was given `needed` by name. A full-access token (no
148+ * scopes, or made before scopes) is not: those were made for git, the API
149+ * and MCP, and reading someone's conversations is not what they were
150+ * given, so they reach Chat only with "Use the website as you".
151+ */
152+function grants(scopes: readonly string[] | null | undefined, needed: Scope): boolean {
153+ return Array.isArray(scopes) && tokenAllows(scopes, needed);
154+}
155+
156+/**
157+ * The person a token may open a live socket as (`path` is a
158+ * lib/socket-ticket.ts `socketPath`): a token with the website permission
159+ * opens any of them; a person's token with `chat:read` opens Chat's live
160+ * socket and the person's feed (`/-/live`), never an artifact's.
161+ */
162+export function socketUser(viewer: Viewer, path: string): User | null {
163+ const website = websiteUser(viewer);
164+ if (website) return website;
165+ const user = personsToken(viewer);
166+ if (!user || !grants(user.token?.scopes, "chat:read")) return null;
167+ return path === "/-/live" || new RegExp(`^/${WORKSPACE}/-/chat/live$`).test(path) ? user : null;
168+}
169+
75170 /** Why a token was not accepted, for the 401. */
76171 export const TOKEN_REFUSED =
77172 "This access token cannot be used on the website: it is not valid, has expired, or does not have “Use the website as you” turned on. " +
168263 if (token === null) return { kind: "none" };
169264 const { pathname } = new URL(request.url);
170265 const method = request.method.toUpperCase();
171− const user = token.startsWith("g1t_") ? websiteUser(await lookup(token)) : null;
266+ const viewer = token.startsWith("g1t_") ? await lookup(token) : null;
267+ // The website permission opens the site; a token's scopes open only the
268+ // few routes that need them.
269+ const user = websiteUser(viewer) ?? scopedUser(viewer, pathname, method);
172270 if (!user) return wantsData(pathname, method) ? { kind: "refused", status: 401, body: TOKEN_REFUSED } : { kind: "signed-out" };
173271 let form: { get(name: string): unknown } | null = null;
174272 if (readsForm(pathname, method)) {
+22−5
11 import { env } from "cloudflare:workers";
22 import { data } from "react-router";
33
4−import { notifyClient, type NotifyPreferencesChange, type PresenceChange, type PushSubscriptionJson } from "@g1t/contracts";
4+import { notifyClient, type NotifyPreferencesChange, type PresenceChange, type PushDevice, type PushSubscriptionJson } from "@g1t/contracts";
55 import type { SoundSettingsChange } from "@g1t/contracts/sounds";
66
77 import type { Route } from "./+types/api";
1515
1616 /**
1717 * The signed-in person's notification settings, as JSON: `GET` their
18− * status (with `?endpoint=` for whether this browser gets pushes), `POST`
19− * a change: `subscribe`, `unsubscribe`, `preferences`, `test`,
20− * `presence` (your status, being away, Do Not Disturb), or `sounds` (what
21− * you hear when chat moves, kept with the account by identity).
18+ * status (with `?endpoint=` for whether this browser gets pushes, and how
19+ * many phones do), `POST` a change: `subscribe`, `unsubscribe`,
20+ * `register_device` and `unregister_device` (the phone app, with its Expo
21+ * push token), `preferences`, `test`, `presence` (your status, being away,
22+ * Do Not Disturb), or `sounds` (what you hear when chat moves, kept with
23+ * the account by identity).
24+ *
25+ * The phone app calls this with its access token and no `Origin`, which
26+ * the same-origin check lets through (lib/same-origin.ts).
2227 */
2328 export async function loader({ context, request }: Route.LoaderArgs) {
2429 const user = requireUser(context, request);
3136 intent?: string;
3237 subscription?: PushSubscriptionJson & { expirationTime?: number | null };
3338 endpoint?: string;
39+ device?: PushDevice;
40+ token?: string;
3441 preferences?: NotifyPreferencesChange;
3542 change?: PresenceChange | SoundSettingsChange;
3643 };
4956 }
5057 case "unsubscribe":
5158 return Response.json(await notify.unsubscribe(user, String(sent.endpoint ?? "")));
59+ case "register_device": {
60+ const d = sent.device;
61+ if (typeof d?.token !== "string" || (d.platform !== "android" && d.platform !== "ios")) return Response.json({ ok: false }, { status: 400 });
62+ const device: PushDevice = { token: d.token, platform: d.platform, name: typeof d.name === "string" ? d.name : null };
63+ const registered = await notify.registerDevice(user, device);
64+ // The service checks the token is Expo's.
65+ return Response.json(registered, { status: registered.ok ? 200 : 400 });
66+ }
67+ case "unregister_device":
68+ return Response.json(await notify.unregisterDevice(user, String(sent.token ?? "")));
5269 case "preferences":
5370 return Response.json(await notify.setPreferences(user, sent.preferences ?? {}));
5471 case "test":
+8−3
22 import { getViewer, roleIn } from "../../lib/session.server";
33 import { issueTicket, socketPath } from "../../lib/socket-ticket";
44 import { ticketSecret } from "../../lib/socket-ticket.server";
5−import { bearerToken } from "../../lib/website-token";
5+import { bearerToken, socketUser } from "../../lib/website-token";
66
77 const PRIVATE = { "cache-control": "no-store", "x-robots-tag": "noindex" };
88
1111 * `GET /-/live/ticket?path=/<workspace>/-/chat/live` answers
1212 * `{ ticket, expires_at }`, good for a minute on that socket alone
1313 * (lib/socket-ticket.ts). Only a request signed in by a token gets one: a
14− * session's sockets carry its cookie and need none.
14+ * session's sockets carry its cookie and need none. A token let in by its
15+ * `chat:read` scope rather than the website permission gets one for Chat's
16+ * socket and the feed (`/-/live`) alone (lib/website-token.ts `socketUser`).
1517 */
1618 export async function loader({ context, request }: Route.LoaderArgs) {
1719 const viewer = getViewer(context);
1820 const token = bearerToken(request);
19− if (!viewer || !token || !viewer.token?.website) {
21+ if (!viewer || !token || !viewer.token) {
2022 return Response.json(
2123 { message: "Socket tickets are for pages opened with an access token; a signed-in browser's sockets use its session." },
2224 { status: viewer ? 400 : 401, headers: PRIVATE },
2426 }
2527 const socket = socketPath(new URL(request.url).searchParams.get("path") ?? "");
2628 if (!socket) return Response.json({ message: "Which socket? `path` is one of the site's live sockets." }, { status: 400, headers: PRIVATE });
29+ if (!socketUser(viewer, socket.path)) {
30+ return Response.json({ message: "This access token can open Chat's sockets and your feed, not this one." }, { status: 403, headers: PRIVATE });
31+ }
2732 if (socket.workspace && !roleIn(viewer, socket.workspace)) {
2833 return Response.json({ message: "Not found" }, { status: 404, headers: PRIVATE });
2934 }
+4−1
1010
1111 /**
1212 * What a chat page asks for as it runs, as JSON, without loading a page:
13− * the sidebar again, a thread's replies, older messages; and everything it
13+ * the sidebar again, a thread's replies, older messages, the channels to
14+ * join (`?browse=1`, for the phone app); and everything it
1415 * sends: messages, read marks, stars, new channels and direct messages,
1516 * and the actions pressed on cards.
1617 * Every answer is `{ ok, value }` or `{ ok: false, error }`, the services'
2526 const answer = await safely<unknown>(() => {
2627 // The workspace's own emoji, for the picker and for drawing :name: (components/emoji).
2728 if (url.searchParams.get("emoji")) return chat.listEmoji(slug, viewer);
29+ // The channels to find and join, as Browse lists them; for the phone app.
30+ if (url.searchParams.get("browse")) return chat.browse(slug, viewer, { archived: false });
2831 if (!channel) return chat.sidebar(slug, viewer);
2932 return chat.messages(slug, channel, viewer, {
3033 thread_root: url.searchParams.get("thread"),
+6−2
335335
336336 Every access token and OAuth sign-in has scopes, `resource:level`:
337337 `repo`, `code`, `issues`, `pull_requests`, `workflows`, `checks`, `deployments`, `memory`,
338−`account`, `notifications`, `access`, `webhooks`, `secrets`, `runners`, `models`, `artifacts` (read, write or admin
338+`account`, `notifications`, `chat`, `access`, `webhooks`, `secrets`, `runners`, `models`, `artifacts` (read, write or admin
339339 as each has them), `agents:run`, `workspace:read` and `workspace:admin`. A higher
340340 level includes the lower. A token may expire. It reaches every workspace
341341 and repository its owner can (a workspace's token, that workspace only);
349349 token reaches one workspace (or only its owner's account), all, chosen or
350350 only public repositories of it, with permissions (`contents`, `issues`,
351351 `workflows`, …) mapped to these scopes; a workspace may require an owner to approve one first. For
352−an agent, use the Agent preset (every read scope but `runners:read`, plus `code:write`,
352+an agent, use the Agent preset (every read scope but `runners:read` and `chat:read`, plus `code:write`,
353353 `issues:write`, `pull_requests:write`, `agents:run`, `memory:write`,
354354 `notifications:write`). For CI, the CI preset (`repo:read`, `code:read`,
355355 `code:write`, `packages:read`, `packages:write`, `workflows:read`,
356356 `workflows:write`, `deployments:read`, `deployments:write`). `models:write` (sending requests through the AI
357357 Gateway, which spends AI credit) is in no preset but full access.
358+`chat:read` and `chat:write` are in no preset either: with them a person's
359+token, OAuth or personal, uses Chat's JSON routes (`/{workspace}/-/chat/api`)
360+and live sockets on the website without "Use the website as you", and
361+nothing else there.
358362 OAuth clients may send `scope`;
359363 the person can untick any; asking for none gives the Agent preset. Tokens
360364 from device sign-in (above) have full access. Guide:
+55−6
2424 pub enum Resource {
2525 Account,
2626 Notifications,
27+ /// Chat: the conversations a person is in (channels, direct messages,
28+ /// threads), read and answered as them on the site's chat routes and
29+ /// live sockets. A person's own; no preset gives it.
30+ Chat,
2731 Workspace,
2832 Billing,
2933 Repo,
5054 }
5155
5256 impl Resource {
53− pub const ALL: [Resource; 22] = [
57+ pub const ALL: [Resource; 23] = [
5458 Resource::Repo,
5559 Resource::Code,
5660 Resource::Security,
6569 Resource::Memory,
6670 Resource::Account,
6771 Resource::Notifications,
72+ Resource::Chat,
6873 Resource::Workspace,
6974 Resource::Billing,
7075 Resource::Access,
7984 match self {
8085 Resource::Account => "account",
8186 Resource::Notifications => "notifications",
87+ Resource::Chat => "chat",
8288 Resource::Workspace => "workspace",
8389 Resource::Billing => "billing",
8490 Resource::Repo => "repo",
107113 match self {
108114 Resource::Account => "Your account",
109115 Resource::Notifications => "Notifications",
116+ Resource::Chat => "Chat",
110117 Resource::Workspace => "Workspaces",
111118 Resource::Billing => "Billing",
112119 Resource::Repo => "Repositories",
198205 AccountWrite,
199206 NotificationsRead,
200207 NotificationsWrite,
208+ ChatRead,
209+ ChatWrite,
201210 WorkspaceRead,
202211 WorkspaceAdmin,
203212 BillingRead,
219228
220229 impl Scope {
221230 /// Every scope, grouped by resource, least first.
222− pub const ALL: [Scope; 45] = [
231+ pub const ALL: [Scope; 47] = [
223232 Scope::RepoRead,
224233 Scope::RepoWrite,
225234 Scope::RepoAdmin,
248257 Scope::AccountWrite,
249258 Scope::NotificationsRead,
250259 Scope::NotificationsWrite,
260+ Scope::ChatRead,
261+ Scope::ChatWrite,
251262 Scope::WorkspaceRead,
252263 Scope::WorkspaceAdmin,
253264 Scope::BillingRead,
297308 Scope::AccountWrite => "account:write",
298309 Scope::NotificationsRead => "notifications:read",
299310 Scope::NotificationsWrite => "notifications:write",
311+ Scope::ChatRead => "chat:read",
312+ Scope::ChatWrite => "chat:write",
300313 Scope::WorkspaceRead => "workspace:read",
301314 Scope::WorkspaceAdmin => "workspace:admin",
302315 Scope::BillingRead => "billing:read",
383396 Scope::AccountWrite => "Change your email addresses, make invites, answer invitations, pin projects and star repositories",
384397 Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch",
385398 Scope::NotificationsWrite => "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories",
399+ Scope::ChatRead => "Read the conversations you are in: channels, direct messages, threads and reactions, live",
400+ Scope::ChatWrite => "Send, edit and delete your messages, react, mark conversations read, and answer cards in chat",
386401 Scope::WorkspaceRead => "Read workspace settings, invites, integrations, model routes, teams and rulesets",
387402 Scope::WorkspaceAdmin => "Create and delete workspaces, invite members, connect integrations, create, change and delete teams, and change the workspace's rulesets",
388403 Scope::BillingRead => "See a workspace's usage, budget, AI credit and invoices",
478493 impl Resource {
479494 pub fn group(self) -> ResourceGroup {
480495 match self {
481− Resource::Account | Resource::Notifications => ResourceGroup::Account,
496+ Resource::Account | Resource::Notifications | Resource::Chat => ResourceGroup::Account,
482497 Resource::Workspace | Resource::Billing | Resource::Runners | Resource::Models | Resource::Artifacts => ResourceGroup::Workspace,
483498 _ => ResourceGroup::Repository,
484499 }
591606
592607 /// Its scopes; `None` for full access.
593608 pub fn scopes(self) -> Option<Vec<Scope>> {
594− let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered());
609+ // Chat is not in any: a person's conversations go to an application
610+ // only when it asks for them by name.
611+ let reads = || {
612+ Scope::ALL
613+ .into_iter()
614+ .filter(|scope| scope.level() == Level::Read && scope.offered() && scope.resource() != Resource::Chat)
615+ };
595616 match self {
596617 Preset::ReadOnly => Some(reads().collect()),
597618 Preset::Agent => {
14891510 assert!(scopes.contains(&Scope::AgentsRun));
14901511 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
14911512 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered()) {
1492− // Every read offered but the machines work runs on.
1493− assert_eq!(scopes.contains(&read), read != Scope::RunnersRead, "{read:?}");
1513+ // Every read offered but the machines work runs on, and chat.
1514+ assert_eq!(scopes.contains(&read), read != Scope::RunnersRead && read != Scope::ChatRead, "{read:?}");
14941515 }
14951516 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
14961517 assert_eq!(Preset::Full.scopes(), None);
15661587 }
15671588
15681589 #[test]
1590+ fn chat_is_a_persons_own_and_given_only_when_asked_for() {
1591+ for scope in [Scope::ChatRead, Scope::ChatWrite] {
1592+ assert_eq!(scope.resource(), Resource::Chat);
1593+ assert!(!scope.dangerous());
1594+ assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1595+ }
1596+ assert_eq!(Resource::Chat.label(), "Chat");
1597+ assert_eq!(Resource::Chat.group(), ResourceGroup::Account);
1598+ assert!(Scope::ChatWrite.includes(Scope::ChatRead));
1599+ assert!(!Scope::ChatRead.includes(Scope::ChatWrite));
1600+ // No preset, and so no OAuth default, gives it.
1601+ for preset in Preset::ALL {
1602+ let scopes = preset.scopes().unwrap_or_default();
1603+ assert!(!scopes.iter().any(|scope| scope.resource() == Resource::Chat), "{}", preset.as_str());
1604+ }
1605+ assert!(!oauth_default().iter().any(|scope| scope.resource() == Resource::Chat));
1606+ // An application that asks for it by name gets it; full access holds it.
1607+ assert_eq!(parse_scopes("chat:read chat:write"), vec![Scope::ChatRead, Scope::ChatWrite]);
1608+ assert!(offered_scopes().contains(&Scope::ChatWrite));
1609+ assert!(everything().contains(&Scope::ChatWrite));
1610+ assert!(TokenAccess::full().allows(Scope::ChatWrite));
1611+ // Only a person's token may hold it.
1612+ let asked = std::collections::BTreeMap::from([("chat".to_owned(), "write".to_owned())]);
1613+ assert_eq!(resolve_permissions(&asked, true), Ok(vec![Scope::ChatWrite]));
1614+ assert!(resolve_permissions(&asked, false).is_err());
1615+ }
1616+
1617+ #[test]
15691618 fn checks_are_reported_with_checks_write_which_ci_gets() {
15701619 assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite));
15711620 assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite));
+3−0
158158 "stage": "core",
159159 // The private half of the VAPID key pair browser pushes are signed
160160 // with; without it, notifications are live in open tabs only.
161+ // EXPO_ACCESS_TOKEN, for pushes to the phone app, is optional
162+ // (only for an Expo project with enhanced push security), so it
163+ // is not listed.
161164 "secrets": ["VAPID_PRIVATE_KEY"],
162165 "setup": [
163166 "The VAPID key pair for browser push: `node scripts/ops/vapid-keys.mjs` prints both halves and stores nothing. Put the public half in VAPID_PUBLIC_KEY in services/notify/wrangler.jsonc, and the private half with `npx wrangler secret put VAPID_PRIVATE_KEY` in services/notify"
+0−0

Binary or large file; its contents are not shown.

+1−1
44 "private": true,
55 "type": "module",
66 "license": "MIT",
7− "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts" },
7+ "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts", "./oauth": "./src/oauth.ts", "./customize": "./src/customize.ts", "./skills": "./src/skills.ts" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+15−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { CUSTOMIZE, CUSTOMIZE_SECTIONS, customizePath, sectionOfListing } from "./customize.ts";
5+
6+test("every Marketplace listing has a place in Customize", () => {
7+ assert.equal(sectionOfListing("integration"), "connectors");
8+ assert.equal(sectionOfListing("extension"), "plugins");
9+ for (const section of CUSTOMIZE_SECTIONS) assert.equal(CUSTOMIZE[section].id, section);
10+});
11+
12+test("a section's site path names its workspace", () => {
13+ assert.equal(customizePath("connectors", "acme"), "/acme/-/marketplace/integrations");
14+ assert.equal(customizePath("skills", "a b"), "/a%20b/-/agents/skills");
15+});
+64−0
1+/**
2+ * Customize: one place for what shapes how a workspace's agents work, in
3+ * three parts. It is the Marketplace's successor, and what the phone app
4+ * shows today; the site moves to it in its own change.
5+ *
6+ * - **Skills**: what agents know how to do, in the open SKILL.md format
7+ * (./skill-library.ts): the foundational skills every agent has, and the
8+ * workspace's library.
9+ * - **Connectors**: what agents can reach: the tools a team already uses
10+ * (./connectors.ts). Today's Marketplace integrations.
11+ * - **Plugins**: what adds pages, cards, tools and agent roles to the
12+ * workspace (./marketplace.ts's `ExtensionManifest`). Today's
13+ * Marketplace extensions.
14+ *
15+ * A Marketplace listing (`integration:<id>`, `extension:<id>`) keeps its
16+ * reference; {@link sectionOfListing} says which part shows it. No imports
17+ * beyond types, so it runs anywhere: services, the site, the phone.
18+ */
19+
20+import type { ListingKind } from "./marketplace";
21+
22+export type CustomizeSection = "skills" | "connectors" | "plugins";
23+
24+export const CUSTOMIZE_SECTIONS: readonly CustomizeSection[] = ["skills", "connectors", "plugins"];
25+
26+export type CustomizeSectionInfo = {
27+ id: CustomizeSection;
28+ title: string;
29+ /** One line, for the section's row. */
30+ description: string;
31+ /** Where the site manages it today, with `:workspace` to fill in. */
32+ sitePath: string;
33+};
34+
35+export const CUSTOMIZE: Record<CustomizeSection, CustomizeSectionInfo> = {
36+ skills: {
37+ id: "skills",
38+ title: "Skills",
39+ description: "What agents know how to do: the skills every agent has, and your workspace's library.",
40+ sitePath: "/:workspace/-/agents/skills",
41+ },
42+ connectors: {
43+ id: "connectors",
44+ title: "Connectors",
45+ description: "The tools your team already uses, connected so agents can work with their data.",
46+ sitePath: "/:workspace/-/marketplace/integrations",
47+ },
48+ plugins: {
49+ id: "plugins",
50+ title: "Plugins",
51+ description: "Pages, cards, tools and agent roles for what your team does besides code.",
52+ sitePath: "/:workspace/-/marketplace/extensions",
53+ },
54+};
55+
56+/** The part of Customize that shows a Marketplace listing of this kind. */
57+export function sectionOfListing(kind: ListingKind): CustomizeSection {
58+ return kind === "integration" ? "connectors" : "plugins";
59+}
60+
61+/** A section's site path with its workspace filled in. */
62+export function customizePath(section: CustomizeSection, workspace: string): string {
63+ return CUSTOMIZE[section].sitePath.replace(/:workspace\b/g, encodeURIComponent(workspace));
64+}
+1−0
1515 export * from "./compute";
1616 export * from "./connectors";
1717 export * from "./context";
18+export * from "./customize";
1819 export * from "./d1";
1920 export * from "./datasets";
2021 export * from "./docs";
+17−1
11 /**
22 * Notifications: the notify service (`services/notify`) keeps one feed per
33 * person, a Durable Object holding their open tabs' sockets, their latest
4− * notifications, live unread counts and their browser push subscriptions.
4+ * notifications, live unread counts, their browser push subscriptions and
5+ * the phones the g1t app pushes to.
56 *
67 * Chat tells it about every message a person should count or hear of, the
78 * events service about every inbox item, and the site forwards each tab's
9192 keys: { p256dh: string; auth: string };
9293 };
9394
95+/**
96+ * A phone the g1t app runs on, as the app registers it: the push token
97+ * Expo gave it (`ExponentPushToken[…]`), which platform, and a name to
98+ * know it by (at most 100 characters).
99+ */
100+export type PushDevice = { token: string; platform: "android" | "ios"; name?: string | null };
101+
94102 /** A conversation's unread counts for one person. */
95103 export type ChannelCounts = { channel_id: string; unread: number; mentions: number; muted: boolean };
96104
232240 preferences: NotifyPreferences;
233241 /** How many browsers get pushes. */
234242 subscriptions: number;
243+ /** How many phones the g1t app pushes to (absent from a service older than them). */
244+ devices?: number;
235245 /** Whether this browser's endpoint is one of them, when it was asked about. */
236246 subscribed: boolean;
237247 /** The public key a browser subscribes with; null when push is not set up. */
245255 deliver(items: FeedDelivery[]): Promise<{ ok: boolean }>;
246256 subscribe(user: User, subscription: PushSubscriptionJson, userAgent?: string | null): Promise<{ ok: boolean }>;
247257 unsubscribe(user: User, endpoint: string): Promise<{ ok: boolean }>;
258+ /** A phone the app runs on gets pushes too; the newest 10 are kept. `ok` is false for a token that is not Expo's. */
259+ registerDevice(user: User, device: PushDevice): Promise<{ ok: boolean }>;
260+ /** That phone stops getting them: the app signed out. */
261+ unregisterDevice(user: User, token: string): Promise<{ ok: boolean }>;
248262 status(user: User, endpoint?: string | null): Promise<NotifyStatus>;
249263 setPreferences(user: User, preferences: NotifyPreferencesChange): Promise<NotifyPreferences>;
250264 /** Sends the person a test notification, toasted and pushed whatever their focus. */
293307 deliver: (items) => call("deliver", { items }),
294308 subscribe: (user, subscription, userAgent) => call("subscribe", { user_id: user.id, subscription, user_agent: userAgent ?? null }),
295309 unsubscribe: (user, endpoint) => call("unsubscribe", { user_id: user.id, endpoint }),
310+ registerDevice: (user, device) => call("register_device", { user_id: user.id, device }),
311+ unregisterDevice: (user, token) => call("unregister_device", { user_id: user.id, token }),
296312 status: (user, endpoint) => call("status", { user_id: user.id, endpoint: endpoint ?? null }),
297313 setPreferences: (user, preferences) => call("set_preferences", { user_id: user.id, preferences }),
298314 test: (user) => call("test", { user_id: user.id, username: user.username }),
+16−1
2424 | "memory"
2525 | "account"
2626 | "notifications"
27+ | "chat"
2728 | "workspace"
2829 | "billing"
2930 | "access"
6566 { scope: "account:write", description: "Change your email addresses, make invites, answer invitations, pin projects and star repositories" },
6667 { scope: "notifications:read", description: "See your inbox, its threads, and what you subscribe to and watch" },
6768 { scope: "notifications:write", description: "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories" },
69+ { scope: "chat:read", description: "Read the conversations you are in: channels, direct messages, threads and reactions, live" },
70+ { scope: "chat:write", description: "Send, edit and delete your messages, react, mark conversations read, and answer cards in chat" },
6871 { scope: "workspace:read", description: "Read workspace settings, invites, integrations, model routes, teams and rulesets" },
6972 { scope: "workspace:admin", description: "Create and delete workspaces, invite members, connect integrations, create, change and delete teams, and change the workspace's rulesets" },
7073 { scope: "billing:read", description: "See a workspace's usage, budget, AI credit and invoices" },
115118 { resource: "memory", label: "Memory and context", group: "repository" },
116119 { resource: "account", label: "Your account", group: "account" },
117120 { resource: "notifications", label: "Notifications", group: "account" },
121+ { resource: "chat", label: "Chat", group: "account" },
118122 { resource: "workspace", label: "Workspaces", group: "workspace" },
119123 { resource: "billing", label: "Billing", group: "workspace" },
120124 { resource: "access", label: "Who has access", group: "repository" },
161165 );
162166 }
163167
168+/**
169+ * Whether a token holding `scopes` may do what `needed` gates (Rust:
170+ * `TokenAccess::allows`). Null or absent scopes are full access, which
171+ * holds every scope; unknown names give nothing.
172+ */
173+export function tokenAllows(scopes: readonly string[] | null | undefined, needed: Scope): boolean {
174+ if (scopes === null || scopes === undefined) return true;
175+ return scopes.some((held) => isScope(held) && scopeIncludes(held, needed));
176+}
177+
164178 /** The levels a resource has, least first. */
165179 export function levelsOf(resource: ScopeResource): ScopeLevel[] {
166180 return SCOPES.filter((row) => scopeResource(row.scope) === resource).map((row) => scopeLevel(row.scope));
215229
216230 export type PresetId = "read_only" | "agent" | "ci" | "full";
217231
218−/** Starting points for choosing scopes. `*` is full access. */
232+/** Starting points for choosing scopes. `*` is full access. No preset gives chat: an application gets it only by asking for it. */
219233 export const PRESET_SCOPES = {
220234 read_only: [
221235 "repo:read", "code:read", "security:read", "packages:read", "issues:read", "pull_requests:read", "workflows:read", "checks:read", "deployments:read", "memory:read", "account:read", "notifications:read", "workspace:read", "billing:read", "access:read", "webhooks:read", "secrets:read", "runners:read", "models:read", "artifacts:read",
654668 { id: "memory", label: "Memory & search", scopes: ["memory:read", "memory:write"] },
655669 { id: "account", label: "Account", scopes: ["account:read", "account:write"] },
656670 { id: "notifications", label: "Notifications", scopes: ["notifications:read", "notifications:write"] },
671+ { id: "chat", label: "Chat", scopes: ["chat:read", "chat:write"] },
657672 { id: "workspace", label: "Workspace", scopes: ["workspace:read", "access:read", "webhooks:read", "secrets:read"] },
658673 { id: "billing", label: "Billing", scopes: ["billing:read", "billing:write"] },
659674 { id: "runners", label: "Runners", scopes: ["runners:read"] },
+6−3
12641264 }
12651265
12661266 /// Migration 0041 writes full access out as every permission: the
1267− /// same lists the code made then, before Artifacts was offered, so
1268− /// tokens from before keep exactly what they had.
1267+ /// same lists the code made then, before Artifacts and Chat were
1268+ /// offered, so tokens from before keep exactly what they had.
12691269 #[test]
12701270 fn the_migration_sets_full_access_out_as_every_permission() {
12711271 use g1t_contracts::scopes::{Resource, ResourceGroup, everything};
12721272 let sql = include_str!("../migrations/0041_one_kind_of_token.sql");
1273− let then: Vec<Scope> = everything().into_iter().filter(|scope| scope.resource() != Resource::Artifacts).collect();
1273+ let then: Vec<Scope> = everything()
1274+ .into_iter()
1275+ .filter(|scope| !matches!(scope.resource(), Resource::Artifacts | Resource::Chat))
1276+ .collect();
12741277 assert!(sql.contains(&format!("SET scopes = '{}'", scopes_text(&then))));
12751278 let workspace: Vec<Scope> = then.into_iter().filter(|scope| scope.resource().group() != ResourceGroup::Account).collect();
12761279 assert!(sql.contains(&format!("SET scopes = '{}'", scopes_text(&workspace))));
+145−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { FeedNotification } from "@g1t/contracts";
5+
6+import { ANDROID_CHANNEL, EXPO_BATCH, EXPO_PUSH_URL, cleanDevice, expoMessages, readTickets, sendExpo } from "./expopush.ts";
7+import { pushPayload } from "./prefs.ts";
8+
9+const TOKEN = "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]";
10+
11+function notification(over: Partial<FeedNotification> = {}): FeedNotification {
12+ return {
13+ id: "m1",
14+ kind: "mention",
15+ workspace: "acme",
16+ title: "Ana in #general",
17+ body: "ship it?",
18+ href: "/acme/chat/general",
19+ actor: { kind: "user", id: "u1", name: "Ana", avatar: null, avatar_seed: null },
20+ channel_id: "chn_1",
21+ thread_root: "msg_0",
22+ created_at: "2026-10-11T12:00:00Z",
23+ ...over,
24+ };
25+}
26+
27+test("a device is an Expo push token, a platform and maybe a name", () => {
28+ assert.deepEqual(cleanDevice({ token: TOKEN, platform: "android", name: " Pixel 9 " }), { token: TOKEN, platform: "android", name: "Pixel 9" });
29+ assert.deepEqual(cleanDevice({ token: "ExpoPushToken[abc-123_x]", platform: "ios" }), { token: "ExpoPushToken[abc-123_x]", platform: "ios", name: null });
30+ assert.equal(cleanDevice({ token: TOKEN, platform: "ios", name: "x".repeat(300) })?.name?.length, 100);
31+ assert.equal(cleanDevice({ token: TOKEN, platform: "ios", name: 7 })?.name, null);
32+ for (const bad of [
33+ null,
34+ "ExponentPushToken[x]",
35+ { token: TOKEN, platform: "web" },
36+ { token: TOKEN },
37+ { token: "fcm:abcdef", platform: "android" },
38+ { token: "ExponentPushToken[]", platform: "android" },
39+ { token: "ExponentPushToken[a b]", platform: "android" },
40+ { token: "ExponentPushToken[abc]x", platform: "android" },
41+ { token: `ExponentPushToken[${"a".repeat(200)}]`, platform: "android" },
42+ { token: 42, platform: "android" },
43+ ]) {
44+ assert.equal(cleanDevice(bad), null, JSON.stringify(bad));
45+ }
46+});
47+
48+test("a message says what a browser push says, with where it is in chat", () => {
49+ const n = notification({ body: "x".repeat(300) });
50+ const [message] = expoMessages([TOKEN], pushPayload(n), n);
51+ assert.deepEqual(message, {
52+ to: TOKEN,
53+ title: "Ana in #general",
54+ body: "x".repeat(240),
55+ sound: "default",
56+ priority: "high",
57+ ttl: 12 * 3600,
58+ channelId: ANDROID_CHANNEL,
59+ threadId: "chat:chn_1",
60+ data: { href: "/acme/chat/general", workspace: "acme", kind: "mention", tag: "chat:chn_1", channel_id: "chn_1", thread_root: "msg_0", card: null },
61+ });
62+ assert.equal(ANDROID_CHANNEL, "messages");
63+});
64+
65+test("what is not said to you goes at normal priority; one with no conversation threads by itself", () => {
66+ const inbox = notification({ kind: "inbox", id: "in_9", channel_id: null, thread_root: null });
67+ const [message] = expoMessages([TOKEN], pushPayload(inbox), inbox);
68+ assert.equal(message.priority, "normal");
69+ assert.equal(message.threadId, "inbox:in_9");
70+ assert.equal(message.data.channel_id, null);
71+ assert.equal(message.data.thread_root, null);
72+});
73+
74+test("a card's place travels with it", () => {
75+ const n = notification({
76+ card: { channel_id: "chn_1", message_id: "msg_5", actions: [{ id: "open", label: "Open", style: "default", confirm: null, input: null, href: "/x" }] },
77+ });
78+ const [message] = expoMessages([TOKEN], pushPayload(n), n);
79+ assert.deepEqual(message.data.card, { channel_id: "chn_1", message_id: "msg_5" });
80+ assert.equal(message.threadId, "mention:m1");
81+});
82+
83+test("tickets: ok counts, DeviceNotRegistered is gone, anything else is an error", () => {
84+ const tokens = ["ExpoPushToken[a]", "ExpoPushToken[b]", "ExpoPushToken[c]", "ExpoPushToken[d]"];
85+ const result = readTickets(tokens, {
86+ data: [
87+ { status: "ok", id: "t1" },
88+ { status: "error", message: "gone", details: { error: "DeviceNotRegistered" } },
89+ { status: "error", message: "too big", details: { error: "MessageTooBig" } },
90+ ],
91+ });
92+ assert.equal(result.sent, 1);
93+ assert.deepEqual(result.gone, ["ExpoPushToken[b]"]);
94+ assert.deepEqual(result.errors, ["MessageTooBig", "no ticket"]);
95+ const refused = readTickets(tokens, { errors: [{ code: "PUSH_TOO_MANY_EXPERIENCE_IDS" }] });
96+ assert.equal(refused.sent, 0);
97+ assert.deepEqual(refused.gone, []);
98+ assert.equal(refused.errors.length, 1);
99+});
100+
101+test("sending: 100 to a request, JSON with Expo's headers, the access token only when set", async () => {
102+ const seen: { url: string; init: RequestInit }[] = [];
103+ const fetcher = (async (url: string, init: RequestInit) => {
104+ seen.push({ url, init });
105+ const messages = JSON.parse(String(init.body)) as { to: string }[];
106+ const data = messages.map((m) =>
107+ m.to.endsWith("[t7]") ? { status: "error", message: "gone", details: { error: "DeviceNotRegistered" } } : { status: "ok", id: m.to },
108+ );
109+ return Response.json({ data });
110+ }) as unknown as typeof fetch;
111+ const tokens = Array.from({ length: 250 }, (_, i) => `ExpoPushToken[t${i}]`);
112+ const n = notification();
113+ const result = await sendExpo(tokens, pushPayload(n), n, { accessToken: "secret" }, fetcher);
114+ assert.deepEqual(
115+ seen.map((s) => JSON.parse(String(s.init.body)).length),
116+ [EXPO_BATCH, EXPO_BATCH, 50],
117+ );
118+ assert.equal(seen[0].url, EXPO_PUSH_URL);
119+ assert.equal(seen[0].init.method, "POST");
120+ assert.deepEqual(seen[0].init.headers, { accept: "application/json", "content-type": "application/json", authorization: "Bearer secret" });
121+ assert.deepEqual(result, { sent: 249, gone: ["ExpoPushToken[t7]"], errors: [] });
122+
123+ seen.length = 0;
124+ await sendExpo(tokens.slice(0, 1), pushPayload(n), n, {}, fetcher);
125+ assert.equal((seen[0].init.headers as Record<string, string>).authorization, undefined);
126+});
127+
128+test("a failed request counts nothing and the others still count", async () => {
129+ let call = 0;
130+ const fetcher = (async (_url: string, init: RequestInit) => {
131+ call++;
132+ if (call === 1) throw new Error("network down");
133+ if (call === 2) return Response.json({ errors: [{ code: "RATE_LIMIT" }] }, { status: 429 });
134+ const messages = JSON.parse(String(init.body)) as unknown[];
135+ return Response.json({ data: messages.map(() => ({ status: "ok", id: "x" })) });
136+ }) as unknown as typeof fetch;
137+ const tokens = Array.from({ length: 210 }, (_, i) => `ExpoPushToken[t${i}]`);
138+ const n = notification();
139+ const result = await sendExpo(tokens, pushPayload(n), n, {}, fetcher);
140+ assert.equal(result.sent, 10);
141+ assert.deepEqual(result.gone, []);
142+ assert.equal(result.errors.length, 2);
143+ assert.match(result.errors.join("\n"), /network down/);
144+ assert.match(result.errors.join("\n"), /429/);
145+});
+156−0
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+}
+74−7
99 * focus and page (`serializeAttachment`), which survive hibernation.
1010 *
1111 * Kept in the object's own SQLite storage: the latest notifications, the
12− * unread counts per conversation, push subscriptions and preferences, and
12+ * unread counts per conversation, push subscriptions (browsers) and
13+ * devices (the phone app, pushed through Expo: src/expopush.ts) and
14+ * preferences, and
1315 * the person's status, Do Not Disturb and whether they set themselves away,
1416 * and when they were last on each workspace's Home page (src/visits.ts).
1517 *
3537 type FeedSeed,
3638 type NotifyPreferences,
3739 type NotifyStatus,
40+ type PushDevice,
3841 type PushSubscriptionJson,
3942 } from "@g1t/contracts";
4043
4144 import { applyCounts, totals } from "./counts.ts";
42−import { cleanNotification, decide, mergePreferences, pushPayload, readPreferences, type TabState } from "./prefs.ts";
45+import { cleanDevice, sendExpo } from "./expopush.ts";
46+import { cleanNotification, decide, mergePreferences, pushPayload, readPreferences, type PushPayload, type TabState } from "./prefs.ts";
4347 import {
4448 OFFLINE_GRACE_MS,
4549 applyChange,
6266 VAPID_PUBLIC_KEY?: string;
6367 VAPID_PRIVATE_KEY?: string;
6468 VAPID_SUBJECT?: string;
69+ /**
70+ * A secret, optional: the access token Expo's push service asks for once
71+ * the Expo project turns on enhanced push security. Without it, pushes to
72+ * phones go unauthenticated, which Expo takes otherwise.
73+ */
74+ EXPO_ACCESS_TOKEN?: string;
6575 /** One presence room per workspace (src/room.ts); without it, presence stays in the person's own tabs. */
6676 ROOMS?: DurableObjectNamespace<Room>;
6777 };
8191 const MAX_SUBSCRIPTIONS = 20;
8292 /** The longest endpoint taken. */
8393 const MAX_ENDPOINT = 2000;
94+/** The most phones one person gets pushes on. */
95+const MAX_DEVICES = 10;
8496
8597 export class Feed extends DurableObject<FeedEnv> {
8698 private readonly sql: SqlStorage;
98110 this.sql.exec(
99111 `CREATE TABLE IF NOT EXISTS subscriptions (endpoint TEXT PRIMARY KEY, p256dh TEXT NOT NULL, auth TEXT NOT NULL, user_agent TEXT, created_at INTEGER NOT NULL)`,
100112 );
113+ // The phone app's Expo push tokens (src/expopush.ts).
114+ this.sql.exec(`CREATE TABLE IF NOT EXISTS devices (token TEXT PRIMARY KEY, platform TEXT NOT NULL, name TEXT, created_at INTEGER NOT NULL)`);
101115 this.sql.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
102116 // When the person was last on each workspace's Home page (src/visits.ts).
103117 this.sql.exec(`CREATE TABLE IF NOT EXISTS visits (workspace TEXT PRIMARY KEY, seen_at INTEGER NOT NULL, previous_at INTEGER)`);
149163 return this.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM subscriptions").one().n;
150164 }
151165
166+ private deviceCount(): number {
167+ return this.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM devices").one().n;
168+ }
169+
152170 // ── Presence ────────────────────────────────────────────────────────────
153171
154172 private kept(): Kept {
399417 return true;
400418 }
401419
402− /** Tells the person: open tabs at once, then browsers when none is in front of them. */
420+ /** Tells the person: open tabs at once, then browsers and phones when none is in front of them. */
403421 private async tell(notification: FeedNotification, test = false): Promise<number> {
404422 if (!test && !this.keep(notification)) return 0;
405− const subscriptions = this.subscriptionCount();
423+ // Somewhere to push: a browser or a phone.
424+ const subscriptions = this.subscriptionCount() + this.deviceCount();
406425 const now = Date.now();
407426 // Do Not Disturb: kept and counted, neither toasted nor pushed.
408427 const quiet = dndOn(this.kept().dnd_until, now);
411430 return decision.push ? this.push(notification) : 0;
412431 }
413432
433+ /** Pushes to every browser and phone at once; one failing does not stop the other. */
434+ private async push(notification: FeedNotification): Promise<number> {
435+ const payload = pushPayload(notification);
436+ const results = await Promise.allSettled([this.pushBrowsers(payload), this.pushDevices(notification, payload)]);
437+ let sent = 0;
438+ for (const result of results) {
439+ if (result.status === "fulfilled") sent += result.value;
440+ else console.error("notify: pushing failed", result.reason);
441+ }
442+ return sent;
443+ }
444+
414445 /** Pushes to every browser subscribed; drops the ones the push service says are gone. */
415− private async push(notification: FeedNotification): Promise<number> {
446+ private async pushBrowsers(payload: PushPayload): Promise<number> {
416447 const vapid = this.vapid();
417448 if (!vapid) return 0;
418449 const subscriptions = this.sql.exec<{ endpoint: string; p256dh: string; auth: string }>("SELECT endpoint, p256dh, auth FROM subscriptions").toArray();
419− const payload = pushPayload(notification);
420450 const results = await Promise.allSettled(
421451 subscriptions.map((s) => sendPush(s, payload, { vapid, urgency: payload.urgent ? "high" : "normal", topic: payload.tag, ttl: 12 * 3600 })),
422452 );
433463 return sent;
434464 }
435465
466+ /** Pushes to every phone through Expo; drops the ones Expo says the app is gone from. */
467+ private async pushDevices(notification: FeedNotification, payload: PushPayload): Promise<number> {
468+ const tokens = this.sql.exec<{ token: string }>("SELECT token FROM devices").toArray().map((r) => r.token);
469+ if (!tokens.length) return 0;
470+ const result = await sendExpo(tokens, payload, notification, { accessToken: this.env.EXPO_ACCESS_TOKEN });
471+ for (const token of result.gone) this.sql.exec("DELETE FROM devices WHERE token = ?", token);
472+ for (const error of result.errors) console.error("notify: a push to a phone failed", error);
473+ return result.sent;
474+ }
475+
436476 // ── RPC (called by the Worker, src/index.ts) ────────────────────────────
437477
438478 async notify(value: unknown): Promise<{ ok: boolean }> {
509549 return { ok: true };
510550 }
511551
552+ /** A phone the app runs on, by its Expo push token; registering it again moves it to the newest. */
553+ async registerDevice(device: PushDevice): Promise<{ ok: boolean }> {
554+ const clean = cleanDevice(device);
555+ if (!clean) return { ok: false };
556+ this.sql.exec(
557+ "INSERT OR REPLACE INTO devices (token, platform, name, created_at) VALUES (?, ?, ?, ?)",
558+ clean.token,
559+ clean.platform,
560+ clean.name,
561+ Date.now(),
562+ );
563+ // The oldest go first past the limit.
564+ this.sql.exec("DELETE FROM devices WHERE token NOT IN (SELECT token FROM devices ORDER BY created_at DESC LIMIT ?)", MAX_DEVICES);
565+ return { ok: true };
566+ }
567+
568+ async unregisterDevice(token: string): Promise<{ ok: boolean }> {
569+ this.sql.exec("DELETE FROM devices WHERE token = ?", String(token ?? ""));
570+ return { ok: true };
571+ }
572+
512573 async status(endpoint: string | null): Promise<NotifyStatus> {
513574 const subscribed = !!endpoint && this.sql.exec("SELECT 1 FROM subscriptions WHERE endpoint = ?", endpoint).toArray().length > 0;
514− return { preferences: this.preferences(), subscriptions: this.subscriptionCount(), subscribed, vapid_public_key: this.env.VAPID_PUBLIC_KEY || null };
575+ return {
576+ preferences: this.preferences(),
577+ subscriptions: this.subscriptionCount(),
578+ devices: this.deviceCount(),
579+ subscribed,
580+ vapid_public_key: this.env.VAPID_PUBLIC_KEY || null,
581+ };
515582 }
516583
517584 async setPreferences(change: unknown): Promise<NotifyPreferences> {
+9−2
11 /**
2− * The notify service: live notifications, unread counts and browser push.
2+ * The notify service: live notifications, unread counts, and push to
3+ * browsers (Web Push) and to the phone app (through Expo, src/expopush.ts).
34 * One feed per person (src/feed.ts).
45 *
56 * Reached through service bindings only: `POST /rpc/<method>` with
1314 * - events: `notify`, for every inbox item, and `set_inbox`, the count after
1415 * items arrive or are marked, naming the person by username;
1516 * - the site: `subscribe`, `unsubscribe`, `status`, `set_preferences`,
16− * `test`, `presence` and `set_presence`, for the person signed in; and
17+ * `test`, `presence` and `set_presence`, for the person signed in;
18+ * `register_device` and `unregister_device`, for the phone app they
19+ * signed in to (through `/-/notify`); and
1720 * `last_visit` and `mark_visit`, when they were last on a workspace's
1821 * Home page (src/visits.ts);
1922 * - agents and the site: `workspace_presence`, how a workspace's people
9699 return Response.json(await stub.subscribe(args.subscription, typeof args.user_agent === "string" ? args.user_agent : null));
97100 case "unsubscribe":
98101 return Response.json(await stub.unsubscribe(String(args.endpoint ?? "")));
102+ case "register_device":
103+ return Response.json(await stub.registerDevice(args.device));
104+ case "unregister_device":
105+ return Response.json(await stub.unregisterDevice(String(args.token ?? "")));
99106 case "status":
100107 return Response.json(await stub.status(typeof args.endpoint === "string" ? args.endpoint : null));
101108 case "set_preferences":
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.