Skip to content

Commit

Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More.

The app signs in with two new scopes, chat:read and chat:write. Chat has no API of its own, so the site now takes a person's own token holding them by name, without "Use the website as you", on Chat's JSON routes, its live socket and the feed's tickets, and notifications:* on /-/notify, and nowhere else: never a page or a page's data, never an artifact's socket. A full-access token does not count. No preset holds the chat scopes, so an application gets Chat only by asking for it. Phones get pushes: the notify service keeps each person's devices beside their browsers and sends through Expo's push service (FCM and APNs) under the same rules as browser push, dropping a device Expo says is gone. The app registers with POST /-/notify register_device after asking once, opens the thread or conversation a tapped notification is about, and unregisters on signing out. The app config has the EAS project, the Android notification icon and channel, and reads Firebase's google-services.json from an EAS file variable. The authentication, notifications, phone app, deploy and self-hosting guides say so.

Claude-Session:
https://claude.ai/code/session_01MQE64a4WNN14F8GELCiuc9
Claudeagent and Claudeagentcommitted Parent7f56ff7Browse files
64 files+3384−4880/64 viewed
+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
+65−26
11 ---
22 title: The phone app
3−description: g1t on Android and iOS, built with Expo. Chat, Code and Customize on your phone, signed in with g1t's own OAuth. Not in the stores yet; this is how it works and how to build it.
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.
44 ---
55
66 The phone app is g1t on Android and iOS, with its own screens for what
7−you do away from a desk: answering in Chat, reviewing what agents made in
8−Code, and seeing the skills, connectors and plugins your workspace can
9−use in Customize. Everything else opens on the site, in your phone's
10−browser.
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.
1111
1212 It is not in Google Play or the App Store yet. Android comes first. Until
1313 then, it is built from `apps/mobile` in
1414 [g1t's repository](https://g1t.sh/flagon-io/g1t), as
1515 [For operators](#for-operators-building-and-releasing) says.
1616
17+The bar along the bottom is the site's phone bar:
18+
1719 | Tab | What is on it |
1820 | --- | --- |
19−| **Chat** | The channels you are in and your direct messages, with unread counts. A conversation shows its messages and agents' cards, live while it is open, and you write back from the box at the bottom. |
20−| **Code** | The workspace's repositories, each with its pull requests and issues, open or closed. 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. |
21−| **Customize** | **Skills** (what agents know how to do), **Connectors** (the tools they can reach, the Marketplace's integrations today) and **Plugins** (what adds pages and tools, the Marketplace's extensions today). Each opens where it is set up on the site. |
22−| **You** | Your workspaces, to switch between them, how you signed in, and **Sign out**. |
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.
2332
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+
2471 ## Signing in
2572
2673 **Sign in with g1t** opens g1t.sh in your phone's browser, where you sign
4188 **Use an access token** signs in with a
4289 [personal access token](/guides/authentication/#access-tokens) you paste
4390 in instead. The token stays on your phone, in its keystore.
44−
45−### Chat and the app's sign-in
4691
47−Chat is served by the site, and the site takes only a browser's session
48−or a token with **Use the website as you** turned on. The app's OAuth
49−sign-in is neither yet, so with it Code and Customize work and Chat says
50−it needs another sign-in. Until the app's sign-in opens Chat, sign in
51−with **Use an access token** and a token with
52−[Use the website as you](/guides/authentication/#use-a-token-on-the-website)
53−turned on. Keep that token as safe as your password: on the website it can
54−do anything you can.
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.
5596
5697 ## Links
5798
79120 ## What's not there yet
80121
81122 - **Store releases.** Google Play first, then the App Store.
82−- **Notifications on your phone.** g1t sends browser and desktop
83− notifications today; push to phones needs g1t to send through Firebase
84− and Apple, which is coming.
85−- **Chat with the app's own sign-in**, as [above](#chat-and-the-apps-sign-in).
86123 - **Home, Notifications, Agents and Artifacts** as tabs of their own.
87124 Their pages open on the site meanwhile.
88−- **Formatting.** Messages, descriptions and comments show their Markdown
89− as written.
90−- **Threads.** A message's replies are counted, and read on the site.
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.
91130 - **https links.** `g1t.sh` addresses open the site in the browser, not
92131 the app, until g1t.sh publishes the files Android and iOS check first.
93132
+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 |
+1−0
4040 # generated native folders
4141 /ios
4242 /android
43+google-services.json
+74−34
1717 sign-in.tsx Sign in with g1t, an access token, or another server
1818 oauth/callback.tsx where the site sends sign-in back (g1t://oauth/callback)
1919 (app)/ signed in: the tabs
20− chat/ conversations, and one conversation, live
20+ chat/ the list, a conversation, a thread, new, browse
2121 code/ repositories, pull requests, issues, diffs
22− customize/ Skills, Connectors, Plugins
23− you/ workspaces, how you signed in, sign out
22+ more/ Customize, notifications, workspaces, sign out
23+ customize/ Skills, Connectors, Plugins (from More)
24+ home/, notifications/, agents/ coming: they open the site
2425 +native-intent.tsx g1t:// links to screens (lib/links.ts)
25− components/ the building blocks, themed (ui.tsx), and Code's
26+ components/ the building blocks, themed (ui.tsx); chat/ and Code's
2627 lib/
2728 server.ts which g1t: site and API addresses
2829 oauth.ts OAuth 2.1 with PKCE against the server's metadata
3031 client.ts every request: refresh, errors, the site or the API
3132 session.tsx who is signed in, to which workspace
3233 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
3337 customize.ts Customize's listings, from @g1t/contracts' catalogs
3438 theme/ packages/theme/tokens.css's colours, for React Native
3539 ```
4549 | --- | --- | --- |
4650 | Who you are, your workspaces | `GET {api}/user` | `Authorization: Bearer g1t_…` |
4751 | Code | `{api}/repos…` REST | the same |
48−| Chat | `{site}/<workspace>/-/chat/api` and its live socket, as the site's own pages use them | the same header, which the site takes only from a token with **Use the website as you**; the socket gets a ticket from `{site}/-/live/ticket` first |
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:*` |
4954 | Sign-in | `{api}/.well-known/oauth-authorization-server`, `{site}/oauth/authorize`, `{api}/oauth/token` | OAuth 2.1, PKCE (S256), public client |
5055
5156 The OAuth client is not registered anywhere: its id encodes its name and
5560 token works once, so `lib/client.ts` shares one refresh between
5661 requests.
5762
58−### Open: Chat with the app's OAuth sign-in
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` |
5977
60−OAuth tokens are made without the website permission
61−(`services/identity/src/oauth.rs` → `issue_oauth_tokens`), and Chat is
62−only on the site, so **an OAuth sign-in reaches Code but not Chat**. The
63−app says so on Chat and offers an access-token sign-in, which works today
64−with a token that has **Use the website as you**. The server needs one of
65−these before the app ships:
78+A full-access token does not count: it reaches Chat only with the website
79+permission.
6680
67−1. **Chat in the REST API** (recommended): `GET/POST /workspaces/:ws/chat…`
68− in `apps/api` with `chat:read` and `chat:write` scopes, calling the chat
69− service as the site does, and a socket ticket the API can mint. It is
70− what the API and MCP will want anyway; the app moves its three calls
71− in `lib/chat.ts` over.
72−2. **Let the site take OAuth tokens for Chat's routes alone**:
73− `website-token.ts` accepts a token without `website` on
74− `/<ws>/-/chat/api`, `/<ws>/-/chat/live` and `/-/live/ticket` when it
75− has new `chat:*` scopes. Smaller, but it widens what the site accepts.
81+### Live, and notifications
7682
77−Turning `website` on for every OAuth token is not one of them: it would
78−let any approved application use the whole site as the person.
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.
7997
8098 ## Run it
8199
102120 call it **g1t in Expo Go**. Its client id encodes that address, so a new
103121 network makes a new client: the app keeps the id it signed in with for
104122 refreshing, and signing in again after a move just adds a new entry to
105−Connected applications. What Go can't do: open from `g1t://` links (they
106−go to an installed build) and show the app's own name and icon.
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.
107127
108128 To develop against a g1t running on your computer, choose **Server** on
109129 the sign-in screen and give its address on your network, such as
139159 Once, by someone with access to the Expo account and Google Play:
140160
141161 1. `npm install -g eas-cli`, then `eas login`.
142−2. In `apps/mobile`, `eas init`. It makes the EAS project and prints its
143− id: put it in `EAS_PROJECT` in `app.config.ts` (the config is code, so
144− `eas init` cannot write it there). Set `EAS_OWNER` in your shell if the
145− project belongs to an organization.
162+2. The EAS project exists (`35fb457b-…`, `EAS_PROJECT` in
163+ `app.config.ts`); `eas whoami` should be an account with access to it.
164+ Set `EAS_OWNER` in your shell if it belongs to an organization. On
165+ expo.dev, the project's GitHub settings take `apps/mobile` as the base
166+ directory.
146167 3. `eas build --profile preview --platform android`. EAS makes and keeps
147168 the upload keystore the first time; say yes. The build ends with a link
148169 and a QR code to install the APK.
160181 iOS is the same with `--platform ios`, an Apple Developer account and the
161182 bundle id `sh.g1t.app`; EAS makes the certificates.
162183
184+### Push notifications on Android
185+
186+Expo's push service sends to Android through Firebase Cloud Messaging,
187+which needs a Firebase project. Once:
188+
189+1. In the [Firebase console](https://console.firebase.google.com), make a
190+ project, and in it an Android app for each package: `sh.g1t.app`,
191+ `sh.g1t.app.preview` and `sh.g1t.app.dev`. Download
192+ `google-services.json` (one file lists all three).
193+2. Give it to EAS as a file variable:
194+ `eas env:create --scope project --name GOOGLE_SERVICES_JSON --type file --value ./google-services.json --visibility secret --environment production --environment preview --environment development`.
195+ `app.config.ts` reads it from there, or a `google-services.json` beside
196+ it locally (ignored by git).
197+3. In Firebase, **Project settings → Service accounts → Generate new private
198+ key**, then `eas credentials` → Android → the production build's package →
199+ **Google Service Account → Push Notifications (FCM V1)**, and upload that
200+ key. Repeat for `sh.g1t.app.preview` and `sh.g1t.app.dev`.
201+4. Build again. Pushes need a build: Expo Go on Android has none.
202+
203+`EXPO_ACCESS_TOKEN` on the notify Worker is needed only if the Expo
204+project turns on **Enhanced push security**.
205+
163206 ### Before the first store release
164207
165−- **Chat with OAuth**, above.
166−- **Push notifications**: `expo-notifications`, and in `services/notify`
167− a subscription kind for Expo push tokens (or FCM and APNs) beside Web
168− Push, registered from the app after sign-in.
169208 - **App Links**: `https://g1t.sh/.well-known/assetlinks.json` naming
170209 `sh.g1t.app` and Play's signing certificate's SHA-256, then an
171210 `intentFilters` entry with `autoVerify` in `app.config.ts`, so g1t.sh
177216 agent; the site's `AgentFace` should be shared and drawn here.
178217 - **The store listing**: screenshots, a privacy policy URL, and Play's
179218 data safety form (the app keeps the server and credentials in the
180− keystore, and sends nothing anywhere but the g1t it is pointed at).
219+ keystore, registers a push token with the g1t it is pointed at, and
220+ sends nothing anywhere else).
181221
182222 ## Notes
183223
+27−3
1+/// <reference types="node" />
2+
3+import { existsSync } from "node:fs";
4+import { join } from "node:path";
5+
16 import type { ConfigContext, ExpoConfig } from "expo/config";
27
38 /**
1116 * | `preview` | `sh.g1t.app.preview` | g1t (beta) |
1217 * | production | `sh.g1t.app` | g1t |
1318 *
14− * The EAS project is set once `eas init` has made one (README.md); until
15− * then, builds and updates are not linked to one.
19+ * The EAS project is g1t's on expo.dev; push notifications on Android
20+ * also need the Firebase credentials uploaded there (README.md).
1621 */
1722
1823 /** The EAS project's id, from `eas init`. `EAS_PROJECT_ID` overrides it. */
19−const EAS_PROJECT: string | undefined = undefined;
24+const EAS_PROJECT = "35fb457b-905c-44cd-9b35-0899e12852dd";
2025
2126 const variant = process.env.APP_VARIANT;
2227 const suffix = variant === "development" ? ".dev" : variant === "preview" ? ".preview" : "";
2631
2732 /** g1t's background, dark as the site is by default (packages/theme/tokens.css `--g1t-bg`). */
2833 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);
2944
3045 export default ({ config }: ConfigContext): ExpoConfig => ({
3146 ...config,
5267 monochromeImage: "./assets/images/android-icon-monochrome.png",
5368 },
5469 predictiveBackGestureEnabled: true,
70+ ...(googleServices ? { googleServicesFile: googleServices } : {}),
5571 softwareKeyboardLayoutMode: "resize",
5672 },
5773 plugins: [
5975 "expo-secure-store",
6076 "expo-web-browser",
6177 [
78+ "expo-notifications",
79+ {
80+ icon: "./assets/images/notification-icon.png",
81+ color: ACCENT,
82+ defaultChannel: "messages",
83+ },
84+ ],
85+ [
6286 "expo-splash-screen",
6387 {
6488 backgroundColor: BACKGROUND,
+0−0

Binary or large file; its contents are not shown.

+4−0
2525 "expo-clipboard": "~57.0.2",
2626 "expo-constants": "~57.0.21",
2727 "expo-crypto": "~57.0.3",
28+ "expo-device": "~57.0.2",
2829 "expo-font": "~57.0.4",
30+ "expo-haptics": "~57.0.3",
2931 "expo-image": "~57.0.5",
3032 "expo-linking": "~57.0.12",
33+ "expo-notifications": "~57.0.22",
3134 "expo-router": "~57.0.25",
3235 "expo-secure-store": "~57.0.4",
3336 "expo-splash-screen": "~57.0.9",
4548 "react-native-worklets": "0.10.1"
4649 },
4750 "devDependencies": {
51+ "@types/node": "^22.20.5",
4852 "@types/react": "~19.2.2",
4953 "sharp": "^0.35.5",
5054 "typescript": "~6.0.3"
+4−1
66 // middle 66% Android keeps whatever the launcher's mask.
77 // - android-icon-monochrome.png: the same in white, for themed icons.
88 // - 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).
911 import { readFile } from "node:fs/promises";
1012 import { fileURLToPath } from "node:url";
1113 import sharp from "sharp";
2931 await draw("android-icon-foreground.png", { size: 1024, scale: 0.46 });
3032 await draw("android-icon-monochrome.png", { size: 1024, scale: 0.46, color: "#ffffff" });
3133 await draw("splash-icon.png", { size: 512, scale: 0.9 });
32−console.log("Drew icon.png, android-icon-foreground.png, android-icon-monochrome.png and splash-icon.png.");
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.");
+79−10
1−import { Tabs, useGlobalSearchParams } from "expo-router";
2−import { Blocks, CircleUser, Code2, MessagesSquare } from "lucide-react-native";
3−import { useEffect } from "react";
1+import * as Notifications from "expo-notifications";
2+import { Tabs, router, useGlobalSearchParams } from "expo-router";
3+import { Bell, Bot, Code2, House, Menu, MessagesSquare } from "lucide-react-native";
4+import { useEffect, useRef } from "react";
45
6+import { Banner } from "@/components/chat/banner";
7+import { FeedProvider, useFeed } from "@/lib/feed";
8+import { routeFor } from "@/lib/links";
9+import { type PushData, registerForPush } from "@/lib/push";
510 import { useSignedIn } from "@/lib/session";
11+import { hasCodeAccess } from "@g1t/contracts";
612 import { useColors } from "@/theme";
713
814 /**
9− * The signed-in app: Chat, Code, Customize and You, as the site's phone
10− * layout has its bottom bar (apps/web/app/components/mobile.tsx). Home,
11− * Notifications, Agents and Artifacts join as they get screens.
15+ * The signed-in app. Its bar is the site's phone bar
16+ * (apps/web/app/components/mobile.tsx): Home, Chat, Notifications, Agents,
17+ * Code, and More. Chat and Code have their screens; the others say so and
18+ * open the site. Customize and your account are under More.
1219 */
1320 export default function AppLayout() {
21+ return (
22+ <FeedProvider>
23+ <PushSetup />
24+ <AppTabs />
25+ <Banner />
26+ </FeedProvider>
27+ );
28+}
29+
30+function AppTabs() {
1431 const colors = useColors();
1532 const { workspace, selectWorkspace } = useSignedIn();
33+ const { unread, mentions } = useFeed();
1634 // A link names its workspace (`/chat?workspace=acme`, lib/links.ts): switch to it.
1735 const { workspace: linked } = useGlobalSearchParams<{ workspace?: string }>();
1836 useEffect(() => {
1937 if (linked && linked.toLowerCase() !== workspace?.slug.toLowerCase()) void selectWorkspace(linked);
2038 }, [linked, workspace?.slug, selectWorkspace]);
39+ const code = !workspace || hasCodeAccess(workspace);
40+ const badge = mentions > 0 ? mentions : unread > 0 ? unread : undefined;
2141
2242 return (
2343 <Tabs
44+ initialRouteName="chat"
2445 screenOptions={{
2546 headerShown: false,
2647 tabBarActiveTintColor: colors.accent,
2748 tabBarInactiveTintColor: colors.muted,
2849 tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.line },
50+ tabBarLabelStyle: { fontSize: 11, fontWeight: "600" },
2951 }}
3052 >
3153 <Tabs.Screen name="index" options={{ href: null }} />
32− <Tabs.Screen name="chat" options={{ title: "Chat", tabBarIcon: ({ color, size }) => <MessagesSquare color={color} size={size} /> }} />
33− <Tabs.Screen name="code" options={{ title: "Code", tabBarIcon: ({ color, size }) => <Code2 color={color} size={size} /> }} />
34− <Tabs.Screen name="customize" options={{ title: "Customize", tabBarIcon: ({ color, size }) => <Blocks color={color} size={size} /> }} />
35− <Tabs.Screen name="you" options={{ title: "You", tabBarIcon: ({ color, size }) => <CircleUser color={color} size={size} /> }} />
54+ <Tabs.Screen name="home" options={{ title: "Home", tabBarIcon: ({ color, size }) => <House color={color} size={size} /> }} />
55+ <Tabs.Screen
56+ name="chat"
57+ options={{
58+ title: "Chat",
59+ tabBarIcon: ({ color, size }) => <MessagesSquare color={color} size={size} />,
60+ tabBarBadge: badge,
61+ tabBarBadgeStyle: { backgroundColor: mentions > 0 ? colors.accent : colors.lineStrong, color: mentions > 0 ? colors.onAccent : colors.fg, fontSize: 11 },
62+ }}
63+ />
64+ <Tabs.Screen name="notifications" options={{ title: "Notifications", tabBarIcon: ({ color, size }) => <Bell color={color} size={size} /> }} />
65+ <Tabs.Screen name="agents" options={{ title: "Agents", tabBarIcon: ({ color, size }) => <Bot color={color} size={size} /> }} />
66+ <Tabs.Screen name="code" options={{ title: "Code", href: code ? undefined : null, tabBarIcon: ({ color, size }) => <Code2 color={color} size={size} /> }} />
67+ <Tabs.Screen name="customize" options={{ href: null }} />
68+ <Tabs.Screen name="more" options={{ title: "More", tabBarIcon: ({ color, size }) => <Menu color={color} size={size} /> }} />
3669 </Tabs>
3770 );
3871 }
72+
73+/**
74+ * Registers the phone for notifications once signed in (asking the first
75+ * time), and opens what a tapped notification is about: the thread or
76+ * conversation, in its workspace.
77+ */
78+function PushSetup() {
79+ const { client, selectWorkspace } = useSignedIn();
80+ const handled = useRef<string | null>(null);
81+ const response = Notifications.useLastNotificationResponse();
82+
83+ useEffect(() => {
84+ void registerForPush(client, true).catch((error: unknown) => console.warn("g1t: registering for notifications failed", error));
85+ }, [client]);
86+
87+ useEffect(() => {
88+ if (!response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return;
89+ const id = response.notification.request.identifier;
90+ if (handled.current === id) return;
91+ handled.current = id;
92+ const data = (response.notification.request.content.data ?? {}) as PushData;
93+ void (async () => {
94+ if (data.workspace) await selectWorkspace(data.workspace);
95+ if (data.channel_id && data.thread_root) {
96+ router.push({ pathname: "/chat/thread", params: { channel: data.channel_id, root: data.thread_root } });
97+ } else if (data.channel_id) {
98+ router.push({ pathname: "/chat/[channel]", params: { channel: data.channel_id } });
99+ } else if (data.href) {
100+ const route = routeFor(data.href);
101+ router.push((route ?? `/open?path=${encodeURIComponent(data.href)}`) as never);
102+ }
103+ })();
104+ }, [response, selectWorkspace]);
105+
106+ return null;
107+}
+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+}
+84−142
1−import type { ChatMessage } from "@g1t/contracts";
2−import { Stack, useLocalSearchParams } from "expo-router";
3−import * as WebBrowser from "expo-web-browser";
4−import { SendHorizontal } from "lucide-react-native";
5−import { useEffect, useMemo, useState } from "react";
6−import { ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
7−import { useSafeAreaInsets } from "react-native-safe-area-context";
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";
85
9−import { Avatar, Badge, ErrorView, Loading, Screen, Text } from "@/components/ui";
10−import { markRead, useChannelLive, useChatSidebar, useMessages, usePostMessage } from "@/lib/chat";
11−import { siteUrl } from "@/lib/server";
12−import { useSignedIn } from "@/lib/session";
13−import { clock } from "@/lib/time";
14−import { radius, space, useColors } from "@/theme";
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";
1511
1612 /**
17− * A conversation: its messages, newest at the bottom, live while open, and
18− * a box to write in. `channel` is a channel's id, or a channel's name when
19− * a link named it (`/acme/-/chat/general`).
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.
2016 */
21−export default function Conversation() {
22− const params = useLocalSearchParams<{ channel: string; title?: string }>();
23− const { client, workspace, server } = useSignedIn();
17+export default function ConversationScreen() {
18+ const params = useLocalSearchParams<{ channel: string }>();
2419 const sidebar = useChatSidebar();
20+ const colors = useColors();
21+ const [menu, setMenu] = useState(false);
2522 const entry = sidebar.data?.entries.find((one) => one.channel.id === params.channel || one.channel.name === params.channel);
2623 // A link by name waits for the sidebar to say which channel it is.
27− const channelId = entry?.channel.id ?? (params.channel.includes("_") ? params.channel : "");
28− const title = params.title ?? (entry ? (entry.channel.kind === "dm" ? entry.title : `#${entry.channel.name}`) : "Chat");
29−
30− const messages = useMessages(channelId);
31− const live = useChannelLive(channelId);
32− const post = usePostMessage(channelId);
33− const [draft, setDraft] = useState("");
34− const colors = useColors();
35− const insets = useSafeAreaInsets();
36−
37− // Newest first, for a list drawn from the bottom up.
38− const newestFirst = useMemo(
39− () => (messages.data?.pages ?? []).flatMap((page) => [...page.messages].reverse()).filter((message) => !message.deleted_at),
40− [messages.data],
41− );
42− const newest = newestFirst[0]?.id;
43− useEffect(() => {
44− if (workspace && channelId && newest) void markRead(client, workspace.slug, channelId, newest);
45− }, [client, workspace, channelId, newest]);
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}`) : "";
4628
47− const send = () => {
48− const body = draft.trim();
49− if (!body || post.isPending) return;
50− post.mutate(body, { onSuccess: () => setDraft("") });
51− };
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+ }
5237
5338 return (
5439 <Screen>
55− <Stack.Screen options={{ title, headerRight: () => <LiveDot status={live} /> }} />
56− <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined} keyboardVerticalOffset={90}>
57− {!channelId && sidebar.isPending ? (
58− <Loading />
59− ) : messages.isPending ? (
60− <Loading />
61− ) : messages.error ? (
62− <ErrorView error={messages.error} retry={() => messages.refetch()} />
63− ) : (
64− <FlatList
65− inverted
66− data={newestFirst}
67− keyExtractor={(message) => message.id}
68− renderItem={({ item }) => <Message message={item} site={server.site} />}
69− onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()}
70− onEndReachedThreshold={0.5}
71− ListFooterComponent={messages.isFetchingNextPage ? <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} /> : null}
72− contentContainerStyle={{ paddingVertical: space.md }}
73− />
74− )}
75− <View style={[styles.composer, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: Math.max(insets.bottom, space.sm) }]}>
76− <TextInput
77− value={draft}
78− onChangeText={setDraft}
79− placeholder={`Message ${title}`}
80− placeholderTextColor={colors.faint}
81− multiline
82− style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]}
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+ }}
8395 />
84− <Pressable accessibilityRole="button" accessibilityLabel="Send" onPress={send} disabled={!draft.trim() || post.isPending} style={styles.send}>
85− {post.isPending ? <ActivityIndicator color={colors.accent} /> : <SendHorizontal color={draft.trim() ? colors.accent : colors.faint} size={22} />}
86− </Pressable>
87− </View>
88− {post.error ? (
89− <Text tone="danger" size="small" style={{ paddingHorizontal: space.lg, paddingBottom: space.sm }}>
90− {post.error.message}
91− </Text>
9296 ) : null}
93− </KeyboardAvoidingView>
97+ </Sheet>
9498 </Screen>
9599 );
96100 }
97−
98−function Message({ message, site }: { message: ChatMessage; site: string }) {
99− const colors = useColors();
100− const { author } = message;
101− const card = message.kind === "card" ? message.card : null;
102− return (
103− <View style={styles.message}>
104− <Avatar name={author.display_name} uri={author.avatar ? `${site}/avatars/${author.avatar}` : null} agent={author.kind === "agent"} />
105− <View style={{ flex: 1, gap: 2 }}>
106− <View style={{ flexDirection: "row", gap: space.sm, alignItems: "baseline" }}>
107− <Text style={{ fontWeight: "600" }}>{author.display_name}</Text>
108− <Text size="small" tone="faint">
109− {clock(message.created_at)}
110− {message.edited_at ? " · edited" : ""}
111− </Text>
112− </View>
113− {message.body ? <Text selectable>{message.body}</Text> : null}
114− {card ? (
115− <Pressable
116− disabled={!card.href}
117− onPress={() => card.href && WebBrowser.openBrowserAsync(siteUrl({ site, api: "" }, card.href))}
118− style={[styles.card, { borderColor: colors.lineStrong, backgroundColor: colors.surface }]}
119− >
120− <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}>
121− <Text style={{ flex: 1, fontWeight: "600" }}>{card.title}</Text>
122− {card.state ? <Badge label={card.state} /> : null}
123− </View>
124− {card.detail ? (
125− <Text size="small" tone="muted">
126− {card.detail}
127− </Text>
128− ) : null}
129− {card.body ? (
130− <Text size="small" numberOfLines={6}>
131− {card.body}
132− </Text>
133− ) : null}
134− </Pressable>
135− ) : null}
136− {message.reply_count > 0 ? (
137− <Text size="small" tone="accent">
138− {message.reply_count === 1 ? "1 reply" : `${message.reply_count} replies`}
139− </Text>
140− ) : null}
141− </View>
142− </View>
143− );
144−}
145−
146−function LiveDot({ status }: { status: "connecting" | "live" | "offline" }) {
147− const colors = useColors();
148− const color = status === "live" ? colors.success : status === "connecting" ? colors.warn : colors.faint;
149− return <View accessibilityLabel={status === "live" ? "Live" : status === "connecting" ? "Connecting" : "Offline"} style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />;
150−}
151−
152−const styles = StyleSheet.create({
153− message: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.sm },
154− card: { borderWidth: 1, borderRadius: radius.md, padding: space.md, gap: space.xs, marginTop: space.xs },
155− composer: { flexDirection: "row", alignItems: "flex-end", gap: space.sm, paddingHorizontal: space.md, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth },
156− input: { flex: 1, minHeight: 40, maxHeight: 140, borderRadius: radius.lg, borderWidth: 1, paddingHorizontal: space.md, paddingVertical: space.sm, fontSize: 15 },
157− send: { width: 40, height: 40, alignItems: "center", justifyContent: "center" },
158−});
+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+}
+126−37
11 import type { ChatSidebarEntry } from "@g1t/contracts";
22 import { Stack, router } from "expo-router";
3−import { Hash, Lock, MessagesSquare } from "lucide-react-native";
4−import { RefreshControl, SectionList, View } from "react-native";
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";
56
6−import { Avatar, Badge, Empty, ErrorView, Loading, Row, Screen, SectionHeader } from "@/components/ui";
7+import { Avatar, Badge, Button, Empty, ErrorView, Loading, Screen, SectionHeader, Text } from "@/components/ui";
78 import { useChatSidebar } from "@/lib/chat";
9+import { useFeed } from "@/lib/feed";
810 import { useSignedIn } from "@/lib/session";
911 import { ago } from "@/lib/time";
10−import { useColors } from "@/theme";
12+import { radius, space, useColors } from "@/theme";
1113
12−/** Chat: the workspace's channels you are in, then your direct messages. */
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+ */
1319 export default function ChatList() {
14− const { workspace, server } = useSignedIn();
20+ const { workspace } = useSignedIn();
1521 const sidebar = useChatSidebar();
1622 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+ );
1750
18− const title = workspace?.name ?? workspace?.slug ?? "Chat";
1951 if (!workspace) {
2052 return (
2153 <Screen>
22− <Stack.Screen options={{ title: "Chat" }} />
54+ {header}
2355 <Empty icon={MessagesSquare} title="No workspace yet" body="Create or join a workspace on g1t, then come back." />
2456 </Screen>
2557 );
2658 }
2759
28− const entries = sidebar.data?.entries ?? [];
29− const sections = [
30− { title: "Channels", data: entries.filter((entry) => entry.channel.kind === "channel" && !entry.channel.archived_at) },
31− { title: "Direct messages", data: entries.filter((entry) => entry.channel.kind === "dm") },
32− ].filter((section) => section.data.length > 0);
33−
3460 return (
3561 <Screen>
36− <Stack.Screen options={{ title }} />
62+ {header}
3763 {sidebar.isPending ? (
3864 <Loading />
3965 ) : sidebar.error ? (
4268 <SectionList
4369 sections={sections}
4470 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+ }
4587 renderSectionHeader={({ section }) => <SectionHeader title={section.title} />}
88+ renderItem={({ item }) => <Conversation entry={item} />}
4689 refreshControl={<RefreshControl refreshing={sidebar.isRefetching} onRefresh={() => sidebar.refetch()} tintColor={colors.accent} />}
47− ListEmptyComponent={<Empty icon={MessagesSquare} title="No conversations yet" body="Join a channel or message someone on g1t." />}
48− renderItem={({ item }) => <Conversation entry={item} site={server.site} />}
49− stickySectionHeadersEnabled={false}
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+ }
50109 />
51110 )}
52111 </Screen>
53112 );
54113 }
55114
56−function Conversation({ entry, site }: { entry: ChatSidebarEntry; site: string }) {
115+function Conversation({ entry }: { entry: ChatSidebarEntry }) {
57116 const colors = useColors();
117+ const { server } = useSignedIn();
118+ const { presence } = useFeed();
58119 const { channel } = entry;
59120 const dm = channel.kind === "dm";
60121 const other = entry.others[0];
61− const leading = dm && other ? (
62− <Avatar name={other.display_name} uri={other.avatar ? `${site}/avatars/${other.avatar}` : null} agent={other.kind === "agent"} />
63− ) : (
64− <View style={{ width: 32, alignItems: "center" }}>
65− {channel.private ? <Lock size={18} color={colors.muted} /> : <Hash size={18} color={colors.muted} />}
66− </View>
67− );
68− const unread = entry.unread > 0 && !entry.muted;
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+
69126 return (
70− <Row
71− leading={leading}
72− title={dm ? entry.title : channel.name ?? entry.title}
73− subtitle={channel.topic ?? (channel.last_message_at ? ago(channel.last_message_at) : undefined)}
74− trailing={
75− entry.mentions > 0 ? <Badge label={`@${entry.mentions}`} tone="accent" /> : unread ? <Badge label={String(entry.unread)} tone="neutral" /> : null
76− }
77− chevron={false}
78− onPress={() => router.push({ pathname: "/chat/[channel]", params: { channel: channel.id, title: dm ? entry.title : `#${channel.name}` } })}
79− />
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>
80160 );
81161 }
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+}
+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+}
+3−0
1+import { TabStack } from "@/components/stack";
2+
3+export default TabStack;
+95−0
1+import { 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, 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 } 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+
23+ const pushRow = {
24+ unsupported: { label: "Not here", detail: "Notifications need a build of the app on a phone: not Expo Go on Android, nor a simulator." },
25+ denied: { label: "Off", detail: "Turned off for g1t in your phone's settings. Tap to open them." },
26+ undetermined: { label: "Off", detail: "Tap to get notified of messages to you, mentions and agents waiting on you." },
27+ granted: { label: "On", detail: "Messages to you, mentions, replies and agents waiting on you, when you're not at g1t elsewhere." },
28+ }[push.data ?? "undetermined"];
29+
30+ return (
31+ <Screen scroll style={{ paddingHorizontal: 0 }}>
32+ <Stack.Screen options={{ title: "More" }} />
33+ <View style={{ flexDirection: "row", gap: space.md, alignItems: "center", paddingHorizontal: space.lg }}>
34+ <Avatar name={name} uri={me.avatar ? `${server.site}/avatars/${me.avatar}` : null} size={48} />
35+ <View style={{ flex: 1 }}>
36+ <Text size="heading">{name}</Text>
37+ <Text size="small" tone="muted">
38+ @{me.username} · {isHosted(server) ? "g1t.sh" : new URL(server.site).host}
39+ </Text>
40+ </View>
41+ </View>
42+
43+ <View>
44+ <Row leading={<Blocks size={22} color={colors.accent} />} title="Customize" subtitle="Skills, connectors and plugins for your agents" onPress={() => router.push("/customize")} />
45+ <Row
46+ leading={<Bell size={22} color={colors.accent} />}
47+ title="Notifications on this phone"
48+ subtitle={pushRow.detail}
49+ trailing={<Badge label={pushRow.label} tone={push.data === "granted" ? "success" : "neutral"} />}
50+ chevron={false}
51+ onPress={
52+ push.data === "denied"
53+ ? () => void Linking.openSettings()
54+ : push.data === "undetermined"
55+ ? () => void registerForPush(client, true).finally(() => queries.invalidateQueries({ queryKey: ["push-state"] }))
56+ : undefined
57+ }
58+ />
59+ </View>
60+
61+ <View>
62+ <SectionHeader title="Workspaces" />
63+ {(me.workspaces ?? []).map((one) => (
64+ <Row
65+ key={one.slug}
66+ leading={<Avatar name={one.name ?? one.slug} uri={one.avatar ? `${server.site}/avatars/${one.avatar}` : null} />}
67+ title={one.name ?? one.slug}
68+ subtitle={`${one.slug} · ${one.role}`}
69+ trailing={one.slug === workspace?.slug ? <Check size={18} color={colors.accent} /> : null}
70+ chevron={false}
71+ onPress={() => selectWorkspace(one.slug)}
72+ />
73+ ))}
74+ </View>
75+
76+ <View>
77+ <SectionHeader title="Signed in" />
78+ <Row
79+ title={oauth ? "With g1t" : "With an access token"}
80+ 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."}
81+ trailing={<ExternalLink size={16} color={colors.faint} />}
82+ chevron={false}
83+ onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, oauth ? "/settings/applications" : "/settings/tokens"))}
84+ />
85+ </View>
86+
87+ <View style={{ paddingHorizontal: space.lg, gap: space.md }}>
88+ <Button title="Sign out" variant="danger" icon={LogOut} onPress={signOut} />
89+ <Text size="small" tone="faint" style={{ textAlign: "center" }}>
90+ g1t {Constants.expoConfig?.version ?? ""}
91+ </Text>
92+ </View>
93+ </Screen>
94+ );
95+}
+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+}
+0−3
1−import { TabStack } from "@/components/stack";
2−
3−export default TabStack;
+0−70
1−import Constants from "expo-constants";
2−import { Stack } from "expo-router";
3−import * as WebBrowser from "expo-web-browser";
4−import { Check, ExternalLink, LogOut } from "lucide-react-native";
5−import { View } from "react-native";
6−
7−import { Avatar, Button, Row, Screen, SectionHeader, Text } from "@/components/ui";
8−import { isHosted, siteUrl } from "@/lib/server";
9−import { useSignedIn } from "@/lib/session";
10−import { space, useColors } from "@/theme";
11−
12−/** You: who is signed in, the workspace the app shows, and signing out. */
13−export default function You() {
14− const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn();
15− const colors = useColors();
16− const name = me.display_username ?? me.username;
17− const oauth = client.credentials.kind === "oauth";
18−
19− return (
20− <Screen scroll style={{ paddingHorizontal: 0 }}>
21− <Stack.Screen options={{ title: "You" }} />
22− <View style={{ flexDirection: "row", gap: space.md, alignItems: "center", paddingHorizontal: space.lg }}>
23− <Avatar name={name} uri={me.avatar ? `${server.site}/avatars/${me.avatar}` : null} size={48} />
24− <View style={{ flex: 1 }}>
25− <Text size="heading">{name}</Text>
26− <Text size="small" tone="muted">
27− {isHosted(server) ? "g1t.sh" : new URL(server.site).host}
28− </Text>
29− </View>
30− </View>
31−
32− <View>
33− <SectionHeader title="Workspaces" />
34− {(me.workspaces ?? []).map((one) => (
35− <Row
36− key={one.slug}
37− leading={<Avatar name={one.name ?? one.slug} uri={one.avatar ? `${server.site}/avatars/${one.avatar}` : null} />}
38− title={one.name ?? one.slug}
39− subtitle={`${one.slug} · ${one.role}`}
40− trailing={one.slug === workspace?.slug ? <Check size={18} color={colors.accent} /> : null}
41− chevron={false}
42− onPress={() => selectWorkspace(one.slug)}
43− />
44− ))}
45− </View>
46−
47− <View>
48− <SectionHeader title="Signed in" />
49− <Row
50− title={oauth ? "With g1t" : "With an access token"}
51− subtitle={
52− oauth
53− ? "End or narrow the app's access in Settings → Connected applications."
54− : "The token you pasted. Delete it in Settings → Access tokens to end it."
55− }
56− trailing={<ExternalLink size={16} color={colors.faint} />}
57− chevron={false}
58− onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, oauth ? "/settings/applications" : "/settings/tokens"))}
59− />
60− </View>
61−
62− <View style={{ paddingHorizontal: space.lg, gap: space.md }}>
63− <Button title="Sign out" variant="danger" icon={LogOut} onPress={signOut} />
64− <Text size="small" tone="faint" style={{ textAlign: "center" }}>
65− g1t {Constants.expoConfig?.version ?? ""}
66− </Text>
67− </View>
68− </Screen>
69− );
70−}
+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+});
+62−0
1+import * as Haptics from "expo-haptics";
2+import { ArrowUp } from "lucide-react-native";
3+import { useState } from "react";
4+import { 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({ draftKey, placeholder, onSend, onTyping }: { draftKey: string; placeholder: string; onSend: (body: string) => void; onTyping: () => void }) {
17+ const colors = useColors();
18+ const insets = useSafeAreaInsets();
19+ const [text, setText] = useState(() => drafts.get(draftKey) ?? "");
20+ const ready = text.trim().length > 0;
21+
22+ const send = () => {
23+ const body = text.trim();
24+ if (!body) return;
25+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined);
26+ onSend(body);
27+ setText("");
28+ drafts.delete(draftKey);
29+ };
30+
31+ return (
32+ <View style={[styles.bar, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: Math.max(insets.bottom, space.sm) }]}>
33+ <TextInput
34+ value={text}
35+ onChangeText={(next) => {
36+ setText(next);
37+ drafts.set(draftKey, next);
38+ if (next.length > 0) onTyping();
39+ }}
40+ placeholder={placeholder}
41+ placeholderTextColor={colors.faint}
42+ multiline
43+ style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]}
44+ />
45+ <Pressable
46+ accessibilityRole="button"
47+ accessibilityLabel="Send"
48+ onPress={send}
49+ disabled={!ready}
50+ style={[styles.send, { backgroundColor: ready ? colors.accent : colors.raised }]}
51+ >
52+ <ArrowUp size={20} color={ready ? colors.onAccent : colors.faint} strokeWidth={2.5} />
53+ </Pressable>
54+ </View>
55+ );
56+}
57+
58+const styles = StyleSheet.create({
59+ bar: { flexDirection: "row", alignItems: "flex-end", gap: space.sm, paddingHorizontal: space.md, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth },
60+ input: { flex: 1, minHeight: 40, maxHeight: 160, borderRadius: radius.lg, borderWidth: 1, paddingHorizontal: space.md, paddingTop: 10, paddingBottom: 10, fontSize: 15 },
61+ send: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center" },
62+});
+283−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 { ArrowDown, Copy, MessageSquareReply, Pencil, Trash2 } from "lucide-react-native";
6+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7+import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, View } from "react-native";
8+
9+import { Composer } from "@/components/chat/composer";
10+import { Message, runsOn } from "@/components/chat/message";
11+import { PromptSheet, Sheet, SheetAction } from "@/components/chat/sheet";
12+import { ErrorView, Loading, Text } from "@/components/ui";
13+import { type Shown, markRead, useChannelLive, useChatActions, useMessages, useSend } from "@/lib/chat";
14+import { useSignedIn } from "@/lib/session";
15+import { viewing } from "@/lib/viewing";
16+import { radius, space, useColors } from "@/theme";
17+
18+const QUICK = ["👍", "❤️", "😂", "🎉", "👀", "✅"];
19+
20+type Row = { kind: "message"; message: Shown; head: boolean } | { kind: "day"; key: string; label: string };
21+
22+/**
23+ * A conversation, or one thread of it: messages newest at the bottom, live
24+ * while open, read as they arrive, and a box to write in. Long-press a
25+ * message to react, reply in a thread, copy, or change or delete your own.
26+ */
27+export function Conversation({ channelId, thread, title }: { channelId: string; thread: string | null; title: string }) {
28+ const colors = useColors();
29+ const queries = useQueryClient();
30+ const { client, workspace, me } = useSignedIn();
31+ const slug = workspace?.slug ?? "";
32+ const messages = useMessages(channelId, thread);
33+ const live = useChannelLive(channelId);
34+ const send = useSend(channelId, thread);
35+ const actions = useChatActions(channelId);
36+ const [picked, setPicked] = useState<Shown | null>(null);
37+ const [editing, setEditing] = useState<Shown | null>(null);
38+ const [scrolledUp, setScrolledUp] = useState(false);
39+ const list = useRef<FlatList<Row>>(null);
40+ const focused = useRef(false);
41+
42+ // While on screen, this conversation's banners and pushes are not shown.
43+ useFocusEffect(
44+ useCallback(() => {
45+ focused.current = true;
46+ viewing.channelId = channelId;
47+ viewing.threadRoot = thread;
48+ return () => {
49+ focused.current = false;
50+ if (viewing.channelId === channelId) {
51+ viewing.channelId = null;
52+ viewing.threadRoot = null;
53+ }
54+ };
55+ }, [channelId, thread]),
56+ );
57+
58+ const all = useMemo(() => {
59+ const pages = messages.data?.pages ?? [];
60+ // Oldest first: the last page read is the oldest.
61+ const ordered = [...pages].reverse().flatMap((page) => page.messages as Shown[]);
62+ // A thread's first page carries the message it is under.
63+ const root = thread ? (pages[0]?.root ?? null) : null;
64+ const shown = ordered.filter((message) => !message.deleted_at && message.id !== root?.id);
65+ return root ? [root as Shown, ...shown] : shown;
66+ }, [messages.data, thread]);
67+
68+ // Rows newest first, for a list drawn from the bottom up, with the day between days.
69+ const rows = useMemo(() => {
70+ const out: Row[] = [];
71+ let day = "";
72+ all.forEach((message, index) => {
73+ const date = new Date(message.created_at);
74+ const label = dayLabel(date);
75+ if (label !== day) {
76+ out.push({ kind: "day", key: `day-${label}-${index}`, label });
77+ day = label;
78+ }
79+ out.push({ kind: "message", message, head: label !== dayLabel(new Date(all[index - 1]?.created_at ?? 0)) || !runsOn(all[index - 1], message) });
80+ });
81+ return out.reverse();
82+ }, [all]);
83+
84+ // Read up to the newest while it is on screen.
85+ const newest = all.filter((message) => !message.local).at(-1)?.id;
86+ useEffect(() => {
87+ if (slug && newest && focused.current && !thread) void markRead(client, queries, slug, channelId, newest);
88+ }, [client, queries, slug, channelId, newest, thread]);
89+
90+ const onCardAction = useCallback(
91+ async (message: ChatMessage, action: CardAction, input?: string | null) => {
92+ const answer = await actions.cardAction.mutateAsync({ message, action: action.id, input });
93+ if (!answer.ok) throw new Error(answer.message ?? "That didn't work.");
94+ return answer.message;
95+ },
96+ [actions.cardAction],
97+ );
98+
99+ const onReact = useCallback((message: ChatMessage, emoji: string, add: boolean) => actions.react.mutate({ message, emoji, add }), [actions.react]);
100+ const onRetry = useCallback((message: Shown) => send(message.body, message), [send]);
101+
102+ const mine = picked?.author.kind === "user" && picked.author.id === me.id;
103+ const typingLine = typingText(live.typing.map((member) => member.display_name));
104+
105+ return (
106+ <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined} keyboardVerticalOffset={Platform.OS === "ios" ? 96 : 0}>
107+ <View style={{ flex: 1 }}>
108+ {messages.isPending ? (
109+ <Loading />
110+ ) : messages.error ? (
111+ <ErrorView error={messages.error} retry={() => messages.refetch()} />
112+ ) : (
113+ <FlatList
114+ ref={list}
115+ inverted
116+ data={rows}
117+ keyExtractor={(row) => (row.kind === "day" ? row.key : row.message.id)}
118+ renderItem={({ item }) =>
119+ item.kind === "day" ? (
120+ <View style={styles.day}>
121+ <View style={[styles.dayLine, { backgroundColor: colors.line }]} />
122+ <Text size="small" tone="faint" style={{ fontWeight: "600" }}>
123+ {item.label}
124+ </Text>
125+ <View style={[styles.dayLine, { backgroundColor: colors.line }]} />
126+ </View>
127+ ) : (
128+ <Message
129+ message={item.message}
130+ head={item.head}
131+ inThread={!!thread}
132+ onLongPress={setPicked}
133+ onReact={onReact}
134+ onRetry={onRetry}
135+ onCardAction={onCardAction}
136+ />
137+ )
138+ }
139+ onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()}
140+ onEndReachedThreshold={0.6}
141+ onScroll={(event) => setScrolledUp(event.nativeEvent.contentOffset.y > 600)}
142+ scrollEventThrottle={100}
143+ ListHeaderComponent={<View style={{ height: space.sm }} />}
144+ ListFooterComponent={
145+ messages.isFetchingNextPage ? (
146+ <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} />
147+ ) : !messages.hasNextPage ? (
148+ <Text size="small" tone="faint" style={{ textAlign: "center", padding: space.xl }}>
149+ {thread ? "The start of this thread." : `The start of ${title}.`}
150+ </Text>
151+ ) : null
152+ }
153+ keyboardDismissMode="interactive"
154+ keyboardShouldPersistTaps="handled"
155+ maintainVisibleContentPosition={{ minIndexForVisible: 1 }}
156+ />
157+ )}
158+ {scrolledUp ? (
159+ <Pressable
160+ accessibilityLabel="Go to newest"
161+ onPress={() => list.current?.scrollToOffset({ offset: 0, animated: true })}
162+ style={[styles.jump, { backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
163+ >
164+ <ArrowDown size={18} color={colors.fg} />
165+ </Pressable>
166+ ) : null}
167+ </View>
168+ <View style={styles.typing}>
169+ <Text size="small" tone="faint" numberOfLines={1}>
170+ {typingLine}
171+ </Text>
172+ {live.status !== "live" ? (
173+ <Text size="small" tone="faint">
174+ {live.status === "connecting" ? "Connecting…" : "Offline, trying again"}
175+ </Text>
176+ ) : null}
177+ </View>
178+ <Composer draftKey={`${channelId}:${thread ?? ""}`} placeholder={thread ? "Reply in thread" : `Message ${title}`} onSend={(body) => send(body)} onTyping={live.sayTyping} />
179+
180+ <Sheet visible={!!picked} onClose={() => setPicked(null)}>
181+ <View style={styles.quick}>
182+ {QUICK.map((emoji) => {
183+ const had = picked?.reactions?.find((one) => one.emoji === emoji)?.me ?? false;
184+ return (
185+ <Pressable
186+ key={emoji}
187+ onPress={() => {
188+ if (picked) onReact(picked, emoji, !had);
189+ setPicked(null);
190+ }}
191+ style={[styles.quickOne, { backgroundColor: had ? colors.accentLow : colors.raised }]}
192+ >
193+ <Text style={{ fontSize: 24, lineHeight: 30 }}>{emoji}</Text>
194+ </Pressable>
195+ );
196+ })}
197+ </View>
198+ {!thread ? (
199+ <SheetAction
200+ icon={MessageSquareReply}
201+ label="Reply in thread"
202+ onPress={() => {
203+ const message = picked;
204+ setPicked(null);
205+ if (message) router.push({ pathname: "/chat/thread", params: { channel: channelId, root: message.id } });
206+ }}
207+ />
208+ ) : null}
209+ <SheetAction
210+ icon={Copy}
211+ label="Copy text"
212+ onPress={() => {
213+ if (picked?.body) void Clipboard.setStringAsync(picked.body);
214+ setPicked(null);
215+ }}
216+ />
217+ {mine && picked?.kind === "text" ? (
218+ <SheetAction
219+ icon={Pencil}
220+ label="Edit"
221+ onPress={() => {
222+ setEditing(picked);
223+ setPicked(null);
224+ }}
225+ />
226+ ) : null}
227+ {mine ? (
228+ <SheetAction
229+ icon={Trash2}
230+ label="Delete"
231+ danger
232+ onPress={() => {
233+ const message = picked;
234+ setPicked(null);
235+ if (!message) return;
236+ Alert.alert("Delete this message?", "It is gone for everyone in the conversation.", [
237+ { text: "Cancel", style: "cancel" },
238+ { text: "Delete", style: "destructive", onPress: () => actions.remove.mutate(message) },
239+ ]);
240+ }}
241+ />
242+ ) : null}
243+ </Sheet>
244+
245+ <PromptSheet
246+ visible={!!editing}
247+ title="Edit message"
248+ initial={editing?.body ?? ""}
249+ multiline
250+ confirm="Save"
251+ onCancel={() => setEditing(null)}
252+ onDone={(body) => {
253+ if (editing && body !== editing.body) actions.edit.mutate({ message: editing, body });
254+ setEditing(null);
255+ }}
256+ />
257+ </KeyboardAvoidingView>
258+ );
259+}
260+
261+function dayLabel(date: Date): string {
262+ const today = new Date();
263+ const yesterday = new Date(today.getTime() - 86_400_000);
264+ if (date.toDateString() === today.toDateString()) return "Today";
265+ if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
266+ return date.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", ...(date.getFullYear() !== today.getFullYear() ? { year: "numeric" } : {}) });
267+}
268+
269+function typingText(names: string[]): string {
270+ if (names.length === 0) return "";
271+ if (names.length === 1) return `${names[0]} is typing…`;
272+ if (names.length === 2) return `${names[0]} and ${names[1]} are typing…`;
273+ return "Several people are typing…";
274+}
275+
276+const styles = StyleSheet.create({
277+ day: { flexDirection: "row", alignItems: "center", gap: space.sm, paddingHorizontal: space.lg, paddingVertical: space.md },
278+ dayLine: { flex: 1, height: StyleSheet.hairlineWidth },
279+ jump: { position: "absolute", right: space.lg, bottom: space.md, width: 40, height: 40, borderRadius: 20, borderWidth: 1, alignItems: "center", justifyContent: "center" },
280+ typing: { flexDirection: "row", justifyContent: "space-between", paddingHorizontal: space.lg, minHeight: 18 },
281+ quick: { flexDirection: "row", justifyContent: "space-around", paddingHorizontal: space.md, paddingBottom: space.sm },
282+ quickOne: { width: 48, height: 48, borderRadius: radius.full, alignItems: "center", justifyContent: "center" },
283+});
+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+});
+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+}
+302−102
1−import type { ChatLiveEvent, ChatMessage, ChatSidebar, MessagePage } from "@g1t/contracts";
2−import { type InfiniteData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3−import { useEffect, useState } from "react";
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";
44
55 import type { Client } from "./client";
6−import { socketUrl } from "./server";
6+import { openSocket } from "./live";
77 import { useSignedIn } from "./session";
88
99 /**
1111 * (apps/web/app/routes/workspace/chat/api.ts):
1212 *
1313 * - `GET /<workspace>/-/chat/api`: the sidebar.
14− * - `GET /<workspace>/-/chat/api?channel=<id>&before=<id>`: a page of messages.
15− * - `POST /<workspace>/-/chat/api` with `{ intent, … }`: post, read, react.
16− * - `wss://<site>/<workspace>/-/chat/live?channel=<id>`: what happens, live.
17− * A socket cannot carry a header, so it asks `GET /-/live/ticket` for a
18− * ticket first (apps/web/app/lib/socket-ticket.ts).
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.
1921 */
2022
2123 const chatPath = (workspace: string) => `/${encodeURIComponent(workspace)}/-/chat/api`;
2224
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+
2344 export function useChatSidebar() {
2445 const { client, workspace } = useSignedIn();
2546 const slug = workspace?.slug ?? "";
2647 return useQuery({
27− queryKey: ["chat", slug, "sidebar"],
48+ queryKey: chatKeys.sidebar(slug),
2849 queryFn: () => client.site<ChatSidebar>(chatPath(slug)),
2950 enabled: !!slug,
3051 });
3152 }
3253
33−export function useMessages(channelId: string, thread?: string | null) {
54+/** The workspace's channels, to find and join (`GET …/chat/api?browse=1`). */
55+export function useBrowseChannels() {
3456 const { client, workspace } = useSignedIn();
3557 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 ?? "";
3668 return useInfiniteQuery({
37− queryKey: ["chat", slug, "messages", channelId, thread ?? null],
69+ queryKey: chatKeys.messages(slug, channelId, thread),
3870 initialPageParam: null as string | null,
3971 queryFn: ({ pageParam }) => {
40− const query = new URLSearchParams({ channel: channelId, limit: "60" });
72+ const query = new URLSearchParams({ channel: channelId, limit: "50" });
4173 if (thread) query.set("thread", thread);
4274 if (pageParam) query.set("before", pageParam);
4375 return client.site<MessagePage>(`${chatPath(slug)}?${query}`);
4779 });
4880 }
4981
50−/** Sends a message; it appears through the live socket, or on the next read. */
51−export function usePostMessage(channelId: string, thread?: string | null) {
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) {
52220 const { client, workspace } = useSignedIn();
53221 const queries = useQueryClient();
54222 const slug = workspace?.slug ?? "";
55− return useMutation({
56− mutationFn: (body: string) =>
57− client.site<ChatMessage>(chatPath(slug), {
58− method: "POST",
59− body: JSON.stringify({ intent: "post", channel_id: channelId, body, thread_root: thread ?? null }),
60− }),
61− onSuccess: (message) => addMessage(queries, slug, message),
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),
62229 });
63−}
64230
65−/** Marks a channel read up to a message. */
66−export function markRead(client: Client, workspace: string, channelId: string, messageId: string) {
67− return client
68− .site(chatPath(workspace), { method: "POST", body: JSON.stringify({ intent: "read", channel_id: channelId, id: messageId }) })
69− .catch(() => undefined);
70−}
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+ });
71235
72−type Pages = InfiniteData<MessagePage, string | null>;
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+ });
73241
74−/** Puts a message into the newest page of its channel's cache, once. */
75−function addMessage(queries: ReturnType<typeof useQueryClient>, workspace: string, message: ChatMessage) {
76− queries.setQueryData<Pages>(["chat", workspace, "messages", message.channel_id, message.thread_root ?? null], (data) => {
77− if (!data) return data;
78− if (data.pages.some((page) => page.messages.some((one) => one.id === message.id))) return data;
79− const [newest, ...rest] = data.pages;
80− return { ...data, pages: [{ ...newest, messages: [...newest.messages, message] }, ...rest] };
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 }),
81246 });
82− void queries.invalidateQueries({ queryKey: ["chat", workspace, "sidebar"] });
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 };
83259 }
84260
85−function replaceMessage(queries: ReturnType<typeof useQueryClient>, workspace: string, message: ChatMessage) {
86− queries.setQueryData<Pages>(["chat", workspace, "messages", message.channel_id, message.thread_root ?? null], (data) =>
87− data
88− ? { ...data, pages: data.pages.map((page) => ({ ...page, messages: page.messages.map((one) => (one.id === message.id ? message : one)) })) }
89− : data,
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,
90265 );
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+ };
91291 }
92292
293+// ── Live ───────────────────────────────────────────────────────────────────
294+
93295 export type LiveStatus = "connecting" | "live" | "offline";
94296
297+/** Who is typing, until when. */
298+type Typing = { member: MemberProfile; until: number };
299+
95300 /**
96− * Keeps a channel's messages live while its screen is open: opens the
97− * channel's socket with a ticket, applies what it says to the cache, and
98− * opens it again (with a new ticket) when it drops.
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).
99304 */
100−export function useChannelLive(channelId: string): LiveStatus {
101− const { client, workspace } = useSignedIn();
305+export function useChannelLive(channelId: string) {
306+ const { client, workspace, me } = useSignedIn();
102307 const queries = useQueryClient();
103308 const slug = workspace?.slug ?? "";
104309 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);
105313
106314 useEffect(() => {
107315 if (!slug || !channelId) return;
108− let socket: WebSocket | null = null;
109− let closed = false;
110− let retry: ReturnType<typeof setTimeout> | null = null;
111− let attempts = 0;
112−
113− const open = async () => {
114− setStatus("connecting");
115− const path = `/${encodeURIComponent(slug)}/-/chat/live`;
116− try {
117− const token = await client.token();
118− const response = await fetch(`${client.server.site}/-/live/ticket?path=${encodeURIComponent(path)}`, {
119− headers: { authorization: `Bearer ${token}`, accept: "application/json" },
120− });
121− if (!response.ok) throw new Error(`ticket ${response.status}`);
122− const { ticket } = (await response.json()) as { ticket: string };
123− if (closed) return;
124− const query = new URLSearchParams({ channel: channelId, ticket });
125− socket = new WebSocket(`${socketUrl(client.server, path)}?${query}`);
126− } catch {
127− return again();
128− }
129− socket.onopen = () => {
130− attempts = 0;
131− setStatus("live");
132− };
133− socket.onmessage = (event) => {
134− let live: ChatLiveEvent;
135− try {
136− live = JSON.parse(String(event.data)) as ChatLiveEvent;
137− } catch {
138− return;
139− }
140− if (live.type === "message.created") addMessage(queries, slug, live.message);
141− else if (live.type === "message.updated") replaceMessage(queries, slug, live.message);
142− else if (live.type === "message.deleted" || live.type.startsWith("reaction.")) {
143− void queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] });
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;
144341 }
145− };
146− socket.onclose = () => {
147− if (!closed) again();
148− };
342+ },
343+ });
344+ send.current = socket.send;
345+ return () => {
346+ send.current = null;
347+ socket.close();
149348 };
349+ }, [client, queries, slug, channelId, me.id]);
150350
151− const again = () => {
152− setStatus("offline");
153− if (closed) return;
154− attempts += 1;
155− retry = setTimeout(open, Math.min(30_000, 1_000 * 2 ** Math.min(attempts, 5)));
156− };
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]);
157358
158− void open();
159− return () => {
160− closed = true;
161− if (retry) clearTimeout(retry);
162− socket?.close();
163− };
164− }, [client, queries, slug, channelId]);
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]);
165365
166− return status;
366+ return { status, typing: typing.map((one) => one.member), sayTyping };
167367 }
+5−0
8080 return answer.value;
8181 }
8282
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+
8388 /** The current access token, refreshed first when it is about to expire. */
8489 async token(): Promise<string> {
8590 const credentials = this.session.credentials;
+120−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) => setActive(state === "active"));
50+ return () => subscription.remove();
51+ }, []);
52+
53+ useEffect(() => {
54+ if (!active || !slug) return;
55+ const socket = openSocket(client, "/-/live", { workspace: slug }, {
56+ onOpen: (send) => {
57+ sendRef.current = send;
58+ send({ type: "state", focused: true, path: pathRef.current });
59+ },
60+ onReconnect: () => void queries.invalidateQueries({ queryKey: chatKeys.all(slug) }),
61+ onMessage: (data) => {
62+ const event = data as FeedEvent;
63+ if (event.type === "counts" && event.workspace === slug) {
64+ setCounts({ unread: event.chat_unread, mentions: event.chat_mentions });
65+ void Notifications.setBadgeCountAsync(event.chat_unread + event.chat_mentions).catch(() => undefined);
66+ const per = new Map(event.per_channel.map((row) => [row.channel_id, row]));
67+ queries.setQueryData<ChatSidebar>(chatKeys.sidebar(slug), (sidebar) => {
68+ if (!sidebar) return sidebar;
69+ const known = new Set(sidebar.entries.map((entry) => entry.channel.id));
70+ // A conversation the app has not seen yet: read the sidebar again.
71+ if ([...per.keys()].some((id) => !known.has(id))) void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
72+ return {
73+ ...sidebar,
74+ entries: sidebar.entries.map((entry) => {
75+ const row = per.get(entry.channel.id);
76+ if (row) return { ...entry, unread: row.unread, mentions: row.mentions };
77+ return event.complete ? { ...entry, unread: 0, mentions: 0 } : entry;
78+ }),
79+ };
80+ });
81+ } else if (event.type === "notification" && event.toast) {
82+ const note = event.notification;
83+ if (note.workspace !== slug || (note.channel_id && note.channel_id === viewing.channelId)) return;
84+ setBanner(note);
85+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => undefined);
86+ } else if (event.type === "presence" && event.workspace === slug) {
87+ setPresence((before) => {
88+ const next = event.full ? new Map<string, Presence>() : new Map(before);
89+ for (const person of event.people) next.set(person.user_id, person.presence);
90+ return next;
91+ });
92+ }
93+ },
94+ });
95+ return () => {
96+ sendRef.current = null;
97+ socket.close();
98+ };
99+ }, [active, client, queries, slug]);
100+
101+ // Where the app is, as a tab says where it is.
102+ useEffect(() => {
103+ sendRef.current?.({ type: "state", focused: true, path });
104+ }, [path]);
105+
106+ // A banner goes by itself.
107+ useEffect(() => {
108+ if (!banner) return;
109+ const timer = setTimeout(() => setBanner(null), 5000);
110+ return () => clearTimeout(timer);
111+ }, [banner]);
112+
113+ return (
114+ <FeedContext.Provider value={{ ...counts, banner, dismiss: () => setBanner(null), presence }}>{children}</FeedContext.Provider>
115+ );
116+}
117+
118+export function useFeed(): FeedState {
119+ return useContext(FeedContext);
120+}
+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+}
+5−2
2828
2929 /**
3030 * 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 has no scope:
32− * see the README's "Chat and OAuth tokens".
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.
3334 */
3435 export const SCOPES = [
36+ "chat:read",
37+ "chat:write",
3538 "account:read",
3639 "workspace:read",
3740 "repo:read",
+103−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+}
+3−0
66 import { ApiError, Client } from "./client";
77 import { type Credentials, clearCredentials, loadCredentials, loadServer, saveCredentials, saveServer } from "./credentials";
88 import { signInWithOAuth } from "./oauth";
9+import { unregisterPush } from "./push";
910 import type { Server } from "./server";
1011
1112 /**
126127 },
127128 signOut: async () => {
128129 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);
129132 if (server) await signedOut(server);
130133 },
131134 changeServer: async (server) => {
+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 };
+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.

+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":
+10−0
190190 // Its own notification: a later message in the conversation does not replace it.
191191 assert.equal(payload.tag, "approval:approval:ses_1:2000000");
192192 });
193+
194+test("a person with only the phone app is pushed to: the feed counts browsers and phones together", () => {
195+ const dm = { kind: "dm" as const, workspace: "acme" };
196+ const browsers = 0;
197+ const phones = 1;
198+ assert.deepEqual(decide({ prefs: dms, notification: dm, tabs: [], subscriptions: browsers + phones, now: NOW }), { toast: true, push: true });
199+ // The same rules hold: not while a tab is in front of them, nor in Do Not Disturb.
200+ assert.equal(decide({ prefs: dms, notification: dm, tabs: [{ focused: true, seen_at: NOW }], subscriptions: browsers + phones, now: NOW }).push, false);
201+ assert.equal(decide({ prefs: dms, notification: dm, tabs: [], subscriptions: browsers + phones, now: NOW, dnd: true }).push, false);
202+});
+3−1
3232 // Web Push (RFC 8292): the public half of the VAPID key pair browsers
3333 // subscribe with, and who the push services may contact. The private
3434 // half is the secret VAPID_PRIVATE_KEY (scripts/ops/vapid-keys.mjs).
35− // Empty: no pushes, everything else works.
35+ // Empty: no pushes, everything else works. Pushes to the phone app go
36+ // through Expo and need nothing here; the secret EXPO_ACCESS_TOKEN is
37+ // needed only once the Expo project turns on enhanced push security.
3638 "vars": { "VAPID_PUBLIC_KEY": "BBAhBFy-jgfEKm4IDEWKNN_5Nft9SjHqahXtpuZf1Aohop03XduSZgyat-hhDKXAP2hdtMv8yPX7TfqWyR6vJTo", "VAPID_SUBJECT": "https://g1t.sh" },
3739 "observability": { "enabled": true, "head_sampling_rate": 0.1 }
3840 }