The phone app: Chat in full with phone notifications, plus Code and Customize (#4)
The phone app: Chat in full with phone notifications, plus Code and Customize (Expo, Android first)
114 files+6758−1180/114 viewed
| 42 | 42 | | Slides, designs and dashboards as artifacts | Coming | | |
| 43 | 43 | | Scratchpads: tools, reports and agents anyone can build on a copy of the data, promoted after review | Coming | | |
| 44 | 44 | | Desktop app for Windows, macOS and Linux (Tauri): the site in a window of its own, notifications from the system, a badge, g1t:// links, updates that install themselves | Preview | | |
| 45 | − | | A runner inside the desktop app, and a phone app (Expo) | Coming | | |
| 45 | + | | A runner inside the desktop app | Coming | | |
| 46 | + | | A phone app for Android and iOS (Expo, in `apps/mobile`): Chat, Code and Customize, signed in with OAuth; Android first | Coming | | |
| 46 | 47 | ||
| 47 | 48 | ### Agents | |
| 48 | 49 |
| 82 | 82 | { label: 'Home', slug: 'guides/home' }, | |
| 83 | 83 | { label: 'Notifications', slug: 'guides/notifications' }, | |
| 84 | 84 | { label: 'The desktop app', slug: 'guides/desktop' }, | |
| 85 | + | { label: 'The phone app', slug: 'guides/mobile' }, | |
| 85 | 86 | { label: 'Search and Explore', slug: 'guides/search' }, | |
| 86 | 87 | { label: 'Status and incidents', slug: 'guides/status' }, | |
| 87 | 88 | ], |
| 589 | 589 | | git | As the password, with your username. | | |
| 590 | 590 | | API | `Authorization: Bearer g1t_…` | | |
| 591 | 591 | | 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. | | |
| 593 | 593 | ||
| 594 | 594 | A token is shown once, when it is created; g1t stores only a hash of it. | |
| 595 | 595 | If you lose one, delete it and create another. Delete a token the moment | |
| ⋯ | |||
| 762 | 762 | | Deleting a workspace, and giving it to another owner | A workspace's General settings, and its Members page | | |
| 763 | 763 | | Payment methods: the billing portal, adding a card, subscribing and buying AI credit | A workspace's Billing | | |
| 764 | 764 | ||
| 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 | + | ||
| 765 | 790 | ### Permissions | |
| 766 | 791 | ||
| 767 | 792 | A permission is a resource and a level. A higher level includes the lower | |
| ⋯ | |||
| 806 | 831 | | --- | --- | --- | | |
| 807 | 832 | | Your account | read, read and write | `account:read`, `account:write` | | |
| 808 | 833 | | Notifications | read, read and write | `notifications:read`, `notifications:write` | | |
| 834 | + | | Chat | read, read and write | `chat:read`, `chat:write` | | |
| 809 | 835 | ||
| 810 | 836 | What each level lets a token do is in [scopes](#scopes). On the form, each | |
| 811 | 837 | row says it for the level chosen, and admin and delete levels are shown in | |
| ⋯ | |||
| 839 | 865 | ||
| 840 | 866 | | Preset | Permissions | | |
| 841 | 867 | | --- | --- | | |
| 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. | | |
| 844 | 870 | | 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. | | |
| 845 | 871 | ||
| 846 | 872 | A new personal token starts on Read only; a new workspace token on CI. | |
| ⋯ | |||
| 888 | 914 | | `account:write` | Change your email addresses, make invites, answer invitations, pin projects and star repositories | | |
| 889 | 915 | | `notifications:read` | See your [notifications](/guides/notifications/), its threads, and what you subscribe to and watch | | |
| 890 | 916 | | `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. | | |
| 891 | 919 | | `workspace:read` | Read workspace settings, invites, integrations, model routes and [teams](/guides/teams/) | | |
| 892 | 920 | | `workspace:admin` | Create and delete workspaces, invite members, manage teams, connect integrations | | |
| 893 | 921 | | `billing:read` | See a workspace's [usage, budget, AI credit and invoices](/guides/usage-and-billing/) | | |
| ⋯ | |||
| 1098 | 1126 | everything you can. | |
| 1099 | 1127 | ||
| 1100 | 1128 | 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`, | |
| 1102 | 1130 | `issues:write`, `pull_requests:write`, `agents:run`, `memory:write` and | |
| 1103 | 1131 | `notifications:write`. | |
| 1104 | 1132 | 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). | |
| 1106 | 1135 | ||
| 1107 | 1136 | Applications you have approved are listed in | |
| 1108 | 1137 | [Settings → Connected applications](https://g1t.sh/settings/applications), | |
| 45 | 45 | ||
| 46 | 46 | | Choose | For an agent | | |
| 47 | 47 | | --- | --- | | |
| 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. | | |
| 49 | 49 | | Expires | The shortest that fits the work, such as 30 days. | | |
| 50 | 50 | ||
| 51 | 51 | A token reaches every workspace and repository you can. To keep an agent |
| 391 | 391 | half in `VAPID_PUBLIC_KEY` in `services/notify/wrangler.jsonc` and the | |
| 392 | 392 | private half in the secret `VAPID_PRIVATE_KEY`. | |
| 393 | 393 | ||
| 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 | + | ||
| 394 | 403 | ## Rate limits | |
| 395 | 404 | ||
| 396 | 405 | Every public surface that costs money or sends something is behind a |
| 113 | 113 | page is open, the window shows the web view's own error until it is | |
| 114 | 114 | back; starting without a network shows the app's own page, with a retry. | |
| 115 | 115 | - **Notarization on macOS**, so the first open needs no right-click. | |
| 116 | − | - **A phone app.** | |
| 116 | + | - **A phone app.** It is being built: see [the phone app](/guides/mobile/). | |
| 117 | 117 | ||
| 118 | 118 | ## For operators: releasing the app | |
| 119 | 119 |
| 1 | + | --- | |
| 2 | + | title: The phone app | |
| 3 | + | description: g1t on Android and iOS, built with Expo. Chat with people and agents, live, with notifications on your phone, and Code and Customize, signed in with g1t's own OAuth. Not in the stores yet; this is how it works and how to build it. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | The phone app is g1t on Android and iOS, with its own screens for what | |
| 7 | + | you do away from a desk: talking with your team and its agents in Chat, | |
| 8 | + | with notifications on your phone, reviewing what agents made in Code, | |
| 9 | + | and seeing the skills, connectors and plugins your workspace can use in | |
| 10 | + | Customize. Everything else opens on the site, in your phone's browser. | |
| 11 | + | ||
| 12 | + | It is not in Google Play or the App Store yet. Android comes first. Until | |
| 13 | + | then, it is built from `apps/mobile` in | |
| 14 | + | [g1t's repository](https://g1t.sh/flagon-io/g1t), as | |
| 15 | + | [For operators](#for-operators-building-and-releasing) says. | |
| 16 | + | ||
| 17 | + | The bar along the bottom is the site's phone bar: | |
| 18 | + | ||
| 19 | + | | Tab | What is on it | | |
| 20 | + | | --- | --- | | |
| 21 | + | | **Home** | Coming. Opens Home on the site meanwhile. | | |
| 22 | + | | **Chat** | Your starred conversations, channels and direct messages, with what is unread, moving live. See [Chat](#chat). | | |
| 23 | + | | **Notifications** | Coming. Opens your notifications on the site meanwhile; messages to you already arrive [on the phone](#notifications). | | |
| 24 | + | | **Agents** | Coming. Opens Agents on the site meanwhile; message any agent from Chat. | | |
| 25 | + | | **Code** | The workspace's repositories, each with its pull requests and issues. A pull request shows its description, checks, conversation and changed files, and you can approve it or ask for changes. An issue shows the pull requests made for it, and you can comment. Shown when you use Code in the workspace. | | |
| 26 | + | | **More** | **Customize** (skills, connectors and plugins), whether this phone gets notifications, your workspaces, how you signed in, and **Sign out**. | | |
| 27 | + | ||
| 28 | + | ## Chat | |
| 29 | + | ||
| 30 | + | Chat on the phone is the site's Chat: the same conversations, as they | |
| 31 | + | happen. | |
| 32 | + | ||
| 33 | + | - **Conversations** show messages as the site draws them: bold, italic, | |
| 34 | + | code, lists, quotes, tables and links, with `@mentions`, `#channels` | |
| 35 | + | and `#123` references standing out. Messages by one person a few minutes | |
| 36 | + | apart run together, and each day is marked. | |
| 37 | + | - **Live.** New messages, edits, deletions and reactions appear as they | |
| 38 | + | happen, and the line above the box says who is typing. Unread counts in | |
| 39 | + | the list and on the **Chat** tab move as messages arrive anywhere. | |
| 40 | + | - **Sending** shows your message at once. If it doesn't reach g1t, it | |
| 41 | + | stays, marked **Not sent**: tap it to try again. | |
| 42 | + | - **Agents' cards** work: their buttons (approve more budget, stop a | |
| 43 | + | session, file a draft issue) act from the phone, asking first or asking | |
| 44 | + | for an amount when the card does, and the card updates in place. | |
| 45 | + | - **Long-press a message** to react, reply in a thread, copy it, or edit | |
| 46 | + | or delete your own. Tap a reaction to add yours or take it back. | |
| 47 | + | - **Threads** open from a message's replies, with the message they are | |
| 48 | + | under at the top. | |
| 49 | + | - **⋯** on a conversation stars, mutes or leaves it. | |
| 50 | + | - **New message** (the pen at the top of Chat) messages a person or an | |
| 51 | + | agent, or a few of them together, or makes a channel. **Browse** at the | |
| 52 | + | foot of the list joins public channels. | |
| 53 | + | ||
| 54 | + | A notification for a conversation you have open is not shown; one for | |
| 55 | + | another shows as a banner at the top of the screen. | |
| 56 | + | ||
| 57 | + | ## Notifications | |
| 58 | + | ||
| 59 | + | The phone gets the same notifications as [browser push](/guides/notifications/#on-your-phone): | |
| 60 | + | messages to you, mentions, replies in threads you are in, and agents | |
| 61 | + | waiting on you, under the same rules. Nothing comes while you are using | |
| 62 | + | g1t in a browser or in the app itself, and nothing in | |
| 63 | + | [Do not disturb](/guides/chat/#sounds-and-do-not-disturb). Tapping one | |
| 64 | + | opens the conversation or thread it is about. | |
| 65 | + | ||
| 66 | + | The app asks to notify you the first time you sign in. To change it | |
| 67 | + | later, use **More → Notifications on this phone**, or the phone's own | |
| 68 | + | settings for g1t, where Android lists the **Messages** channel. Signing | |
| 69 | + | out stops them on that phone. | |
| 70 | + | ||
| 71 | + | ## Signing in | |
| 72 | + | ||
| 73 | + | **Sign in with g1t** opens g1t.sh in your phone's browser, where you sign | |
| 74 | + | in as usual (with your password, GitHub, two-factor) and approve the app. | |
| 75 | + | The browser sends you back to the app, signed in. The app never sees your | |
| 76 | + | password. This is [OAuth](/guides/authentication/#signing-in-with-oauth): | |
| 77 | + | the app is listed as **g1t for Android** or **g1t for iOS** in | |
| 78 | + | [Settings → Connected applications](https://g1t.sh/settings/applications), | |
| 79 | + | where **Change access** narrows what it can do and **Sign out** ends it at | |
| 80 | + | once. | |
| 81 | + | ||
| 82 | + | The approval page lists what the app asks for: reading your account, | |
| 83 | + | workspaces, repositories, checks and workflows, reading and writing | |
| 84 | + | issues, pull requests and notifications, recalling memory, reading | |
| 85 | + | artifacts, and putting agents to work. Untick what you would rather it | |
| 86 | + | could not do. | |
| 87 | + | ||
| 88 | + | **Use an access token** signs in with a | |
| 89 | + | [personal access token](/guides/authentication/#access-tokens) you paste | |
| 90 | + | in instead. The token stays on your phone, in its keystore. | |
| 91 | + | ||
| 92 | + | The app asks for `chat:read` and `chat:write` too. They let it use | |
| 93 | + | Chat's routes and live connections on the site, and register for | |
| 94 | + | notifications, and nothing else there: the app's sign-in cannot open the | |
| 95 | + | site's pages as you. | |
| 96 | + | ||
| 97 | + | ## Links | |
| 98 | + | ||
| 99 | + | A `g1t://` link opens the app, as it opens [the desktop app](/guides/desktop/#links): | |
| 100 | + | `g1t://acme/-/chat/dm/chn_1` is that conversation. The app shows the | |
| 101 | + | pages it has screens for: | |
| 102 | + | ||
| 103 | + | | Link | Opens | | |
| 104 | + | | --- | --- | | |
| 105 | + | | `g1t://<workspace>/-/chat`, `…/-/chat/<channel>`, `…/-/chat/dm/<id>` | Chat, or the conversation, in that workspace | | |
| 106 | + | | `g1t://<owner>/<repo>` | The repository | | |
| 107 | + | | `g1t://<owner>/<repo>/pull/<number>`, `…/issues/<number>` | The pull request or issue | | |
| 108 | + | | `g1t://<workspace>/-/marketplace…`, `…/-/agents/skills` | Customize | | |
| 109 | + | ||
| 110 | + | Any other page opens on the site, in the browser. | |
| 111 | + | ||
| 112 | + | ## Your own g1t | |
| 113 | + | ||
| 114 | + | If you [run g1t yourself](/guides/self-hosting/), choose **Server** on the | |
| 115 | + | sign-in screen and enter your site's address, such as | |
| 116 | + | `https://g1t.example.com`. The app takes the API to be `api.` in front of | |
| 117 | + | it (`https://api.g1t.example.com`); if yours is elsewhere, enter it under | |
| 118 | + | **API**. **Use g1t.sh** goes back. | |
| 119 | + | ||
| 120 | + | ## What's not there yet | |
| 121 | + | ||
| 122 | + | - **Store releases.** Google Play first, then the App Store. | |
| 123 | + | - **Home, Notifications, Agents and Artifacts** as tabs of their own. | |
| 124 | + | Their pages open on the site meanwhile. | |
| 125 | + | - **Formatting in Code.** Descriptions and comments show their Markdown | |
| 126 | + | as written; messages in Chat are formatted. | |
| 127 | + | - **Attachments and custom emoji.** Files in messages, and a | |
| 128 | + | workspace's own `:emoji:`, show as their names. | |
| 129 | + | - **Replying from a notification**, without opening the app. | |
| 130 | + | - **https links.** `g1t.sh` addresses open the site in the browser, not | |
| 131 | + | the app, until g1t.sh publishes the files Android and iOS check first. | |
| 132 | + | ||
| 133 | + | ## For operators: building and releasing | |
| 134 | + | ||
| 135 | + | The app lives in `apps/mobile`: [Expo](https://expo.dev) with Expo | |
| 136 | + | Router, in TypeScript, sharing types with the site through | |
| 137 | + | `@g1t/contracts`. It builds with EAS Build. `apps/mobile/README.md` has | |
| 138 | + | the commands for running it on a phone and the first-time setup. | |
| 139 | + | ||
| 140 | + | | Build | Command | What it makes | | |
| 141 | + | | --- | --- | --- | | |
| 142 | + | | Development | `eas build --profile development --platform android` | An APK of `g1t (dev)` (`sh.g1t.app.dev`) that loads its code from your computer. | | |
| 143 | + | | Preview | `npm run build:android:apk -w @g1t/mobile` | An APK of `g1t (beta)` (`sh.g1t.app.preview`) to install on testers' phones. | | |
| 144 | + | | Production | `npm run build:android -w @g1t/mobile` | An Android App Bundle of `g1t` (`sh.g1t.app`) for Google Play. | | |
| 145 | + | ||
| 146 | + | The three install side by side. `eas submit --platform android` sends | |
| 147 | + | the latest production build to Google Play's internal testing track as a | |
| 148 | + | draft. |
| 179 | 179 | The foot of each email says why you got it, and links to | |
| 180 | 180 | [g1t.sh/settings/notifications](https://g1t.sh/settings/notifications). | |
| 181 | 181 | ||
| 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 | + | ||
| 182 | 199 | ## Settings | |
| 183 | 200 | ||
| 184 | 201 | **Settings → Notifications**, at | |
| ⋯ | |||
| 188 | 205 | | Setting | What it does | Default | | |
| 189 | 206 | | --- | --- | --- | | |
| 190 | 207 | | **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 | | |
| 192 | 209 | | **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 | | |
| 193 | 210 | | **Notify me about**, **Browser notifications** | What pops up and is pushed; see [chat notifications](/guides/chat/#notifications). | DMs and mentions; off | | |
| 194 | 211 | | **Email** | One checkbox per reason: you are also emailed when you are told of something for it. | agent waiting, review requested, mentioned | | |
| 64 | 64 | | `services/search` | Runs: site search is SQLite full-text search | | |
| 65 | 65 | | `services/chat` | Runs, with its Durable Objects; custom emoji are kept with avatars | | |
| 66 | 66 | | `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. | | |
| 68 | 68 | | `services/agents` | Runs, but replies need the model proxy, which is off, so an agent answers with a short apology | | |
| 69 | 69 | | `services/billing` | Runs with nothing charged and no usage limit | | |
| 70 | 70 | | `services/integrations` | Runs | |
| 753 | 753 | ||
| 754 | 754 | | | What it does | | |
| 755 | 755 | | --- | --- | | |
| 756 | − | | **The bar along the bottom** | **Home**, **Chat**, **Notifications**, **Agents** and **Code** (for members with Code access), each with what is unread, and **More**. It steps aside while the keyboard is up and inside a conversation. | | |
| 756 | + | | **The bar along the bottom** | **Home**, **Chat**, **Notifications**, **Agents** and **Code** (for members with Code access), each with what is unread, and **More**. It runs flat along the bottom edge, as the rail stands beside the page on a computer, and the page fills the screen above it. The app you are in is marked as on the rail. It steps aside while the keyboard is up and inside a conversation. | | |
| 757 | 757 | | **More** | A panel above the bar with **All apps**, the [Marketplace](/guides/marketplace/), your pinned apps, **Artifacts**, **People**, **Workspace**, and **You and help**: your status, profile and settings, [appearance](#appearance), the documentation, support, status, the keyboard's shortcuts and signing out. | | |
| 758 | − | | **The sidebar button**, at the left of the page's header | Opens the sidebar of the app you are in from the left: the same lists and links as on a computer. Tap outside it, or open a page, and it closes. | | |
| 758 | + | | **The sidebar button**, at the left of the page's header | Opens the sidebar of the app you are in from the left, most of the screen wide: the same lists and links as on a computer. Tap outside it, or open a page, and it closes. | | |
| 759 | 759 | | **The tab you are already on** | Tap it again to open that app's sidebar too. | | |
| 760 | 760 | | **The workspace's name** in the page's header | Switches workspace, or opens your profile or settings. | | |
| 761 | 761 |
| 1 | + | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files | |
| 2 | + | ||
| 3 | + | # dependencies | |
| 4 | + | node_modules/ | |
| 5 | + | ||
| 6 | + | # Expo | |
| 7 | + | .expo/ | |
| 8 | + | dist/ | |
| 9 | + | web-build/ | |
| 10 | + | expo-env.d.ts | |
| 11 | + | ||
| 12 | + | # Native | |
| 13 | + | .kotlin/ | |
| 14 | + | *.orig.* | |
| 15 | + | *.jks | |
| 16 | + | *.p8 | |
| 17 | + | *.p12 | |
| 18 | + | *.key | |
| 19 | + | *.mobileprovision | |
| 20 | + | ||
| 21 | + | # Metro | |
| 22 | + | .metro-health-check* | |
| 23 | + | ||
| 24 | + | # debug | |
| 25 | + | npm-debug.* | |
| 26 | + | yarn-debug.* | |
| 27 | + | yarn-error.* | |
| 28 | + | ||
| 29 | + | # macOS | |
| 30 | + | .DS_Store | |
| 31 | + | *.pem | |
| 32 | + | ||
| 33 | + | # local env files | |
| 34 | + | .env*.local | |
| 35 | + | ||
| 36 | + | # typescript | |
| 37 | + | *.tsbuildinfo | |
| 38 | + | ||
| 39 | + | ||
| 40 | + | # generated native folders | |
| 41 | + | /ios | |
| 42 | + | /android | |
| 43 | + | google-services.json |
| 1 | + | # g1t for Android and iOS | |
| 2 | + | ||
| 3 | + | The phone app: [Expo](https://expo.dev) (SDK 57, React Native 0.86) with | |
| 4 | + | Expo Router, in TypeScript. Chat, Code and Customize, signed in with g1t's | |
| 5 | + | own OAuth. Android is the first target. What it does for people is in the | |
| 6 | + | guide, [The phone app](../docs/src/content/docs/guides/mobile.md). | |
| 7 | + | ||
| 8 | + | ## Layout | |
| 9 | + | ||
| 10 | + | ```text | |
| 11 | + | app.config.ts name, ids and icons per build variant (APP_VARIANT) | |
| 12 | + | eas.json EAS Build profiles: development, preview, production | |
| 13 | + | metro.config.js one React for the app in the monorepo | |
| 14 | + | scripts/icons.mjs draws the icons from packages/theme/mark.svg | |
| 15 | + | src/ | |
| 16 | + | app/ the screens (Expo Router: one file, one route) | |
| 17 | + | sign-in.tsx Sign in with g1t, an access token, or another server | |
| 18 | + | oauth/callback.tsx where the site sends sign-in back (g1t://oauth/callback) | |
| 19 | + | (app)/ signed in: the tabs | |
| 20 | + | chat/ the list, a conversation, a thread, new, browse | |
| 21 | + | code/ repositories, pull requests, issues, diffs | |
| 22 | + | more/ Customize, notifications, workspaces, sign out | |
| 23 | + | customize/ Skills, Connectors, Plugins (from More) | |
| 24 | + | home/, notifications/, agents/ coming: they open the site | |
| 25 | + | +native-intent.tsx g1t:// links to screens (lib/links.ts) | |
| 26 | + | components/ the building blocks, themed (ui.tsx); chat/ and Code's | |
| 27 | + | lib/ | |
| 28 | + | server.ts which g1t: site and API addresses | |
| 29 | + | oauth.ts OAuth 2.1 with PKCE against the server's metadata | |
| 30 | + | credentials.ts the server and credentials, in the phone's keystore | |
| 31 | + | client.ts every request: refresh, errors, the site or the API | |
| 32 | + | session.tsx who is signed in, to which workspace | |
| 33 | + | chat.ts, code.ts the data for Chat and Code (TanStack Query) | |
| 34 | + | live.ts the site's live sockets, with tickets, kept open | |
| 35 | + | feed.tsx counts, banners and presence while the app is open | |
| 36 | + | push.ts notifications on the phone | |
| 37 | + | customize.ts Customize's listings, from @g1t/contracts' catalogs | |
| 38 | + | theme/ packages/theme/tokens.css's colours, for React Native | |
| 39 | + | ``` | |
| 40 | + | ||
| 41 | + | Shared types and catalogs come from `@g1t/contracts` (by subpath, such as | |
| 42 | + | `@g1t/contracts/oauth`, so the app does not bundle every module). | |
| 43 | + | Customize's three parts are defined there too (`customize.ts`), so the | |
| 44 | + | site can move the Marketplace onto the same model. | |
| 45 | + | ||
| 46 | + | ## How it talks to g1t | |
| 47 | + | ||
| 48 | + | | What | Where | How it is signed in | | |
| 49 | + | | --- | --- | --- | | |
| 50 | + | | Who you are, your workspaces | `GET {api}/user` | `Authorization: Bearer g1t_…` | | |
| 51 | + | | Code | `{api}/repos…` REST | the same | | |
| 52 | + | | Chat | `{site}/<workspace>/-/chat/api` and its live socket, as the site's own pages use them | the same header, with `chat:*` ([below](#chat-with-the-apps-sign-in)); a socket gets a ticket from `{site}/-/live/ticket` first | | |
| 53 | + | | Notifications | `{site}/-/notify` and the feed socket `{site}/-/live` | the same, with `notifications:*` | | |
| 54 | + | | Sign-in | `{api}/.well-known/oauth-authorization-server`, `{site}/oauth/authorize`, `{api}/oauth/token` | OAuth 2.1, PKCE (S256), public client | | |
| 55 | + | ||
| 56 | + | The OAuth client is not registered anywhere: its id encodes its name and | |
| 57 | + | redirect (`encodeOAuthClient`), so `g1t for Android` with | |
| 58 | + | `g1t://oauth/callback` is the same client on every phone. Access tokens | |
| 59 | + | last 30 days and are refreshed a minute before they expire; a refresh | |
| 60 | + | token works once, so `lib/client.ts` shares one refresh between | |
| 61 | + | requests. | |
| 62 | + | ||
| 63 | + | ### Chat with the app's sign-in | |
| 64 | + | ||
| 65 | + | Chat has no REST API: the app uses the site's own chat routes. The app | |
| 66 | + | asks for `chat:read`, `chat:write`, `notifications:read` and | |
| 67 | + | `notifications:write`, and the site takes a token holding them by name | |
| 68 | + | (OAuth or personal, without "Use the website as you") on these alone | |
| 69 | + | (`apps/web/app/lib/website-token.ts`, `scopedRoute` and `socketUser`): | |
| 70 | + | ||
| 71 | + | | Route | Needs | | |
| 72 | + | | --- | --- | | |
| 73 | + | | `GET`, `POST /<ws>/-/chat/api` | `chat:read`, `chat:write` | | |
| 74 | + | | `GET /<ws>/-/chat/person/<username>` | `chat:read` | | |
| 75 | + | | `GET /-/live/ticket` for `/<ws>/-/chat/live` and `/-/live` | `chat:read` | | |
| 76 | + | | `GET`, `POST /-/notify` | `notifications:read`, `notifications:write` | | |
| 77 | + | ||
| 78 | + | A full-access token does not count: it reaches Chat only with the website | |
| 79 | + | permission. | |
| 80 | + | ||
| 81 | + | ### Live, and notifications | |
| 82 | + | ||
| 83 | + | - **A conversation's socket** (`lib/chat.ts`, `useChannelLive`) brings new, | |
| 84 | + | changed and deleted messages, reactions and typing, and takes the | |
| 85 | + | app's own typing. | |
| 86 | + | - **The feed** (`lib/feed.tsx`), the socket every tab of the site keeps, | |
| 87 | + | is open while the app is in front: unread counts, notifications (a | |
| 88 | + | banner, `components/chat/banner.tsx`) and presence. It tells g1t the app | |
| 89 | + | is focused, so nothing is pushed to the phone while it is in use; in the | |
| 90 | + | background it closes. | |
| 91 | + | - **Push** (`lib/push.ts`): the app registers its Expo push token with | |
| 92 | + | `POST /-/notify` `{ intent: "register_device", device: { token, platform, name } }` | |
| 93 | + | and takes it back on signing out (`unregister_device`). The notify | |
| 94 | + | service sends through Expo's push service on the Android channel | |
| 95 | + | `messages`, with `data` `{ href, workspace, kind, tag, channel_id, thread_root, card }`; | |
| 96 | + | tapping one opens its thread or conversation. | |
| 97 | + | ||
| 98 | + | ## Run it | |
| 99 | + | ||
| 100 | + | You need Node 22 (the repository's). | |
| 101 | + | ||
| 102 | + | ### In Expo Go (quickest) | |
| 103 | + | ||
| 104 | + | Everything the app uses is in Expo Go for SDK 57, so no native build is | |
| 105 | + | needed: | |
| 106 | + | ||
| 107 | + | ```sh | |
| 108 | + | npm install # at the repository's root: one workspace | |
| 109 | + | cd apps/mobile | |
| 110 | + | npx expo start --go # then scan the QR code with Expo Go | |
| 111 | + | ``` | |
| 112 | + | ||
| 113 | + | Install [Expo Go](https://expo.dev/go) on the phone, or press `a` for an | |
| 114 | + | Android emulator. The phone and the computer must be on the same network | |
| 115 | + | (or use `npx expo start --tunnel`). | |
| 116 | + | ||
| 117 | + | Signing in works in Go: the site sends you back to Go's own address, | |
| 118 | + | `exp://<your computer>:8081/--/oauth/callback`, which g1t accepts as an | |
| 119 | + | application's own scheme. The approval page and Connected applications | |
| 120 | + | call it **g1t in Expo Go**. Its client id encodes that address, so a new | |
| 121 | + | network makes a new client: the app keeps the id it signed in with for | |
| 122 | + | refreshing, and signing in again after a move just adds a new entry to | |
| 123 | + | Connected applications. Chat works in Go in full, live, with banners | |
| 124 | + | while the app is open. What Go can't do: notifications on an Android | |
| 125 | + | phone (Expo took push out of Go there), open from `g1t://` links (they go | |
| 126 | + | to an installed build), and show the app's own name and icon. | |
| 127 | + | ||
| 128 | + | To develop against a g1t running on your computer, choose **Server** on | |
| 129 | + | the sign-in screen and give its address on your network, such as | |
| 130 | + | `http://192.168.1.20:5173` and the API's `http://192.168.1.20:8789` | |
| 131 | + | (`localhost` on a phone is the phone). Run that g1t with its `SITE_URL` | |
| 132 | + | and `API_URL` set to those addresses too: the API's OAuth metadata | |
| 133 | + | names the sign-in page by `SITE_URL`, and the phone opens what it names. | |
| 134 | + | ||
| 135 | + | ### In a development build | |
| 136 | + | ||
| 137 | + | For `g1t://` links, the app's own icon and name, and anything native Go | |
| 138 | + | lacks later (push notifications): | |
| 139 | + | ||
| 140 | + | ```sh | |
| 141 | + | npm run build:android:dev # EAS builds g1t (dev) in the cloud: install it from the link | |
| 142 | + | npx expo start # then open g1t (dev) on the phone; it finds this computer | |
| 143 | + | ``` | |
| 144 | + | ||
| 145 | + | The development build is `g1t (dev)` (`sh.g1t.app.dev`), with | |
| 146 | + | `expo-dev-client`: it loads the app's code from `npx expo start` on your | |
| 147 | + | computer, signs in through `g1t://oauth/callback`, and gets notifications | |
| 148 | + | once Firebase is set up ([below](#push-notifications-on-android)). Build | |
| 149 | + | it again only when a native module or `app.config.ts` changes; code | |
| 150 | + | changes reload live. With Android Studio, `APP_VARIANT=development npx expo | |
| 151 | + | run:android` builds the same app on your computer instead (it makes | |
| 152 | + | `android/`, ignored by git). | |
| 153 | + | ||
| 154 | + | Checks, which CI runs from the root (`npm run typecheck`, `npm test`): | |
| 155 | + | ||
| 156 | + | ```sh | |
| 157 | + | npm run typecheck -w @g1t/mobile | |
| 158 | + | npm test -w @g1t/mobile # lib/*.test.ts under Node | |
| 159 | + | ``` | |
| 160 | + | ||
| 161 | + | ## Set up EAS and ship Android | |
| 162 | + | ||
| 163 | + | Once, by someone with access to the Expo account and Google Play: | |
| 164 | + | ||
| 165 | + | 1. `npm install -g eas-cli`, then `eas login`. | |
| 166 | + | 2. The EAS project exists (`35fb457b-…`, `EAS_PROJECT` in | |
| 167 | + | `app.config.ts`); `eas whoami` should be an account with access to it. | |
| 168 | + | Set `EAS_OWNER` in your shell if it belongs to an organization. On | |
| 169 | + | expo.dev, the project's GitHub settings take `apps/mobile` as the base | |
| 170 | + | directory. | |
| 171 | + | 3. `eas build --profile preview --platform android`. EAS makes and keeps | |
| 172 | + | the upload keystore the first time; say yes. The build ends with a link | |
| 173 | + | and a QR code to install the APK. | |
| 174 | + | 4. In the [Google Play Console](https://play.google.com/console), create | |
| 175 | + | the app with the package `sh.g1t.app`, and turn on Play App Signing. | |
| 176 | + | 5. `npm run build:android` makes the production App Bundle. Upload the | |
| 177 | + | first one by hand on Play's **Internal testing** track: Google requires | |
| 178 | + | that before its API accepts uploads. | |
| 179 | + | 6. For `eas submit`, make a Google Cloud service account with access to | |
| 180 | + | the app in the Play Console, download its JSON key, and add it to the | |
| 181 | + | EAS project's credentials (`eas credentials`). From then on, | |
| 182 | + | `eas submit --platform android --latest` sends a build to internal | |
| 183 | + | testing as a draft (`eas.json`'s `submit.production`). | |
| 184 | + | ||
| 185 | + | iOS is the same with `--platform ios`, an Apple Developer account and the | |
| 186 | + | bundle id `sh.g1t.app`; EAS makes the certificates. | |
| 187 | + | ||
| 188 | + | ### Push notifications on Android | |
| 189 | + | ||
| 190 | + | Expo's push service sends to Android through Firebase Cloud Messaging, | |
| 191 | + | which needs a Firebase project. Once: | |
| 192 | + | ||
| 193 | + | 1. In the [Firebase console](https://console.firebase.google.com), make a | |
| 194 | + | project, and in it an Android app for each package: `sh.g1t.app`, | |
| 195 | + | `sh.g1t.app.preview` and `sh.g1t.app.dev`. Download | |
| 196 | + | `google-services.json` (one file lists all three). | |
| 197 | + | 2. Give it to EAS as a file variable: | |
| 198 | + | `eas env:create --scope project --name GOOGLE_SERVICES_JSON --type file --value ./google-services.json --visibility secret --environment production --environment preview --environment development`. | |
| 199 | + | `app.config.ts` reads it from there, or a `google-services.json` beside | |
| 200 | + | it locally (ignored by git). | |
| 201 | + | 3. In Firebase, **Project settings → Service accounts → Generate new private | |
| 202 | + | key**, then `eas credentials` → Android → the production build's package → | |
| 203 | + | **Google Service Account → Push Notifications (FCM V1)**, and upload that | |
| 204 | + | key. Repeat for `sh.g1t.app.preview` and `sh.g1t.app.dev`. | |
| 205 | + | 4. Build again. Pushes need a build: Expo Go on Android has none. | |
| 206 | + | ||
| 207 | + | `EXPO_ACCESS_TOKEN` on the notify Worker is needed only if the Expo | |
| 208 | + | project turns on **Enhanced push security**. | |
| 209 | + | ||
| 210 | + | ### Before the first store release | |
| 211 | + | ||
| 212 | + | - **App Links**: `https://g1t.sh/.well-known/assetlinks.json` naming | |
| 213 | + | `sh.g1t.app` and Play's signing certificate's SHA-256, then an | |
| 214 | + | `intentFilters` entry with `autoVerify` in `app.config.ts`, so g1t.sh | |
| 215 | + | links open the app. iOS needs `apple-app-site-association` the same way. | |
| 216 | + | - **The brand's faces**: TTF copies of Hanken Grotesk, Bricolage | |
| 217 | + | Grotesque and IBM Plex Mono (packages/theme ships woff2), loaded with | |
| 218 | + | `expo-font`. | |
| 219 | + | - **Agents' faces**: `components/ui.tsx`'s `Avatar` draws a letter for an | |
| 220 | + | agent; the site's `AgentFace` should be shared and drawn here. | |
| 221 | + | - **The store listing**: screenshots, a privacy policy URL, and Play's | |
| 222 | + | data safety form (the app keeps the server and credentials in the | |
| 223 | + | keystore, registers a push token with the g1t it is pointed at, and | |
| 224 | + | sends nothing anywhere else). | |
| 225 | + | ||
| 226 | + | ## Notes | |
| 227 | + | ||
| 228 | + | - **React is pinned.** The site uses a newer React than React Native 0.86 | |
| 229 | + | allows, so the app has its own `react` (19.2.3) and `metro.config.js` | |
| 230 | + | resolves every `react` import to it. `expo-doctor` reports the root's | |
| 231 | + | copy as a duplicate; that is expected here, and the bundle has one. | |
| 232 | + | - **Icons** are drawn from the mark: `npm run icons -w @g1t/mobile`. | |
| 233 | + | - **Versions**: `version` in `app.config.ts` is what people see; | |
| 234 | + | EAS counts build numbers itself (`appVersionSource: remote`). Over-the-air | |
| 235 | + | updates go to builds of the same `version` (`runtimeVersion`). |
| 1 | + | /// <reference types="node" /> | |
| 2 | + | ||
| 3 | + | import { existsSync } from "node:fs"; | |
| 4 | + | import { join } from "node:path"; | |
| 5 | + | ||
| 6 | + | import type { ConfigContext, ExpoConfig } from "expo/config"; | |
| 7 | + | ||
| 8 | + | /** | |
| 9 | + | * The phone app's configuration. `APP_VARIANT` (set by eas.json's | |
| 10 | + | * profiles) picks an identity, so a development build, a preview build | |
| 11 | + | * and the store build install side by side on one phone: | |
| 12 | + | * | |
| 13 | + | * | Variant | Android package / iOS bundle | Name | | |
| 14 | + | * | ------------- | ---------------------------- | ----------- | | |
| 15 | + | * | `development` | `sh.g1t.app.dev` | g1t (dev) | | |
| 16 | + | * | `preview` | `sh.g1t.app.preview` | g1t (beta) | | |
| 17 | + | * | production | `sh.g1t.app` | g1t | | |
| 18 | + | * | |
| 19 | + | * The EAS project is g1t's on expo.dev; push notifications on Android | |
| 20 | + | * also need the Firebase credentials uploaded there (README.md). | |
| 21 | + | */ | |
| 22 | + | ||
| 23 | + | /** The EAS project's id, from `eas init`. `EAS_PROJECT_ID` overrides it. */ | |
| 24 | + | const EAS_PROJECT = "35fb457b-905c-44cd-9b35-0899e12852dd"; | |
| 25 | + | ||
| 26 | + | const variant = process.env.APP_VARIANT; | |
| 27 | + | const suffix = variant === "development" ? ".dev" : variant === "preview" ? ".preview" : ""; | |
| 28 | + | const name = variant === "development" ? "g1t (dev)" : variant === "preview" ? "g1t (beta)" : "g1t"; | |
| 29 | + | const id = `sh.g1t.app${suffix}`; | |
| 30 | + | const projectId = process.env.EAS_PROJECT_ID ?? EAS_PROJECT; | |
| 31 | + | ||
| 32 | + | /** g1t's background, dark as the site is by default (packages/theme/tokens.css `--g1t-bg`). */ | |
| 33 | + | const BACKGROUND = "#0f0f11"; | |
| 34 | + | /** The accent, for the notification icon's tint (`--g1t-accent`). */ | |
| 35 | + | const ACCENT = "#b6a8ff"; | |
| 36 | + | ||
| 37 | + | /** | |
| 38 | + | * Firebase's file for the Android app, which push on Android needs: from | |
| 39 | + | * an EAS file secret in the cloud (`GOOGLE_SERVICES_JSON`), or a copy | |
| 40 | + | * beside this file locally (ignored by git). Without one, everything but | |
| 41 | + | * push works. | |
| 42 | + | */ | |
| 43 | + | const googleServices = process.env.GOOGLE_SERVICES_JSON ?? (existsSync(join(__dirname, "google-services.json")) ? "./google-services.json" : undefined); | |
| 44 | + | ||
| 45 | + | export default ({ config }: ConfigContext): ExpoConfig => ({ | |
| 46 | + | ...config, | |
| 47 | + | name, | |
| 48 | + | slug: "g1t", | |
| 49 | + | owner: process.env.EAS_OWNER, | |
| 50 | + | version: "0.1.0", | |
| 51 | + | orientation: "portrait", | |
| 52 | + | icon: "./assets/images/icon.png", | |
| 53 | + | // g1t:// links, as on the desktop, and where sign-in comes back to. | |
| 54 | + | scheme: "g1t", | |
| 55 | + | userInterfaceStyle: "automatic", | |
| 56 | + | backgroundColor: BACKGROUND, | |
| 57 | + | ios: { | |
| 58 | + | bundleIdentifier: id, | |
| 59 | + | supportsTablet: true, | |
| 60 | + | config: { usesNonExemptEncryption: false }, | |
| 61 | + | }, | |
| 62 | + | android: { | |
| 63 | + | package: id, | |
| 64 | + | adaptiveIcon: { | |
| 65 | + | backgroundColor: BACKGROUND, | |
| 66 | + | foregroundImage: "./assets/images/android-icon-foreground.png", | |
| 67 | + | monochromeImage: "./assets/images/android-icon-monochrome.png", | |
| 68 | + | }, | |
| 69 | + | predictiveBackGestureEnabled: true, | |
| 70 | + | ...(googleServices ? { googleServicesFile: googleServices } : {}), | |
| 71 | + | softwareKeyboardLayoutMode: "resize", | |
| 72 | + | }, | |
| 73 | + | plugins: [ | |
| 74 | + | "expo-router", | |
| 75 | + | "expo-secure-store", | |
| 76 | + | "expo-web-browser", | |
| 77 | + | [ | |
| 78 | + | "expo-notifications", | |
| 79 | + | { | |
| 80 | + | icon: "./assets/images/notification-icon.png", | |
| 81 | + | color: ACCENT, | |
| 82 | + | defaultChannel: "messages", | |
| 83 | + | }, | |
| 84 | + | ], | |
| 85 | + | [ | |
| 86 | + | "expo-splash-screen", | |
| 87 | + | { | |
| 88 | + | backgroundColor: BACKGROUND, | |
| 89 | + | image: "./assets/images/splash-icon.png", | |
| 90 | + | imageWidth: 96, | |
| 91 | + | }, | |
| 92 | + | ], | |
| 93 | + | ], | |
| 94 | + | experiments: { | |
| 95 | + | reactCompiler: true, | |
| 96 | + | }, | |
| 97 | + | runtimeVersion: { policy: "appVersion" }, | |
| 98 | + | ...(projectId ? { updates: { url: `https://u.expo.dev/${projectId}` } } : {}), | |
| 99 | + | extra: { | |
| 100 | + | ...config.extra, | |
| 101 | + | ...(projectId ? { eas: { projectId } } : {}), | |
| 102 | + | }, | |
| 103 | + | }); |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
| 1 | + | { | |
| 2 | + | "cli": { | |
| 3 | + | "version": ">= 16.0.0", | |
| 4 | + | "appVersionSource": "remote" | |
| 5 | + | }, | |
| 6 | + | "build": { | |
| 7 | + | "base": { | |
| 8 | + | "node": "22.22.0" | |
| 9 | + | }, | |
| 10 | + | "development": { | |
| 11 | + | "extends": "base", | |
| 12 | + | "developmentClient": true, | |
| 13 | + | "distribution": "internal", | |
| 14 | + | "env": { "APP_VARIANT": "development" }, | |
| 15 | + | "android": { "buildType": "apk" } | |
| 16 | + | }, | |
| 17 | + | "preview": { | |
| 18 | + | "extends": "base", | |
| 19 | + | "distribution": "internal", | |
| 20 | + | "channel": "preview", | |
| 21 | + | "env": { "APP_VARIANT": "preview" }, | |
| 22 | + | "android": { "buildType": "apk" } | |
| 23 | + | }, | |
| 24 | + | "production": { | |
| 25 | + | "extends": "base", | |
| 26 | + | "channel": "production", | |
| 27 | + | "autoIncrement": true, | |
| 28 | + | "android": { "buildType": "app-bundle" } | |
| 29 | + | } | |
| 30 | + | }, | |
| 31 | + | "submit": { | |
| 32 | + | "production": { | |
| 33 | + | "android": { "track": "internal", "releaseStatus": "draft" } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } |
| 1 | + | // Metro in the g1t monorepo. Expo finds the workspace root and | |
| 2 | + | // @g1t/contracts by itself; what it needs told is that React comes from | |
| 3 | + | // this app alone. The site pins a newer React at the root, and React | |
| 4 | + | // Native must run with the exact React it was built for (19.2.3 with | |
| 5 | + | // Expo SDK 57), so `react` and its subpaths resolve from here wherever | |
| 6 | + | // they are imported, including from packages hoisted to the root. | |
| 7 | + | const path = require("node:path"); | |
| 8 | + | const { getDefaultConfig } = require("expo/metro-config"); | |
| 9 | + | ||
| 10 | + | const config = getDefaultConfig(__dirname); | |
| 11 | + | const here = path.join(__dirname, "package.json"); | |
| 12 | + | ||
| 13 | + | const resolveRequest = config.resolver.resolveRequest; | |
| 14 | + | config.resolver.resolveRequest = (context, moduleName, platform) => { | |
| 15 | + | const pinned = moduleName === "react" || moduleName.startsWith("react/"); | |
| 16 | + | const from = pinned ? { ...context, originModulePath: here } : context; | |
| 17 | + | return resolveRequest ? resolveRequest(from, moduleName, platform) : context.resolveRequest(from, moduleName, platform); | |
| 18 | + | }; | |
| 19 | + | ||
| 20 | + | module.exports = config; |
| 1 | + | { | |
| 2 | + | "name": "@g1t/mobile", | |
| 3 | + | "private": true, | |
| 4 | + | "version": "0.1.0", | |
| 5 | + | "description": "The g1t phone app (Expo): Chat and Code first, signed in with OAuth, for Android and iOS.", | |
| 6 | + | "main": "expo-router/entry", | |
| 7 | + | "scripts": { | |
| 8 | + | "start": "expo start", | |
| 9 | + | "start:go": "expo start --go", | |
| 10 | + | "android": "expo run:android", | |
| 11 | + | "ios": "expo run:ios", | |
| 12 | + | "prebuild": "expo prebuild --clean", | |
| 13 | + | "typecheck": "tsc --noEmit", | |
| 14 | + | "test": "node --test \"src/lib/*.test.ts\"", | |
| 15 | + | "doctor": "expo-doctor", | |
| 16 | + | "icons": "node scripts/icons.mjs", | |
| 17 | + | "build:android:dev": "eas build --platform android --profile development", | |
| 18 | + | "build:android": "eas build --platform android --profile production", | |
| 19 | + | "build:android:apk": "eas build --platform android --profile preview", | |
| 20 | + | "build:ios": "eas build --platform ios --profile production" | |
| 21 | + | }, | |
| 22 | + | "dependencies": { | |
| 23 | + | "@g1t/contracts": "*", | |
| 24 | + | "@tanstack/react-query": "^5.104.1", | |
| 25 | + | "expo": "~57.0.27", | |
| 26 | + | "expo-auth-session": "~57.0.14", | |
| 27 | + | "expo-clipboard": "~57.0.2", | |
| 28 | + | "expo-constants": "~57.0.21", | |
| 29 | + | "expo-crypto": "~57.0.3", | |
| 30 | + | "expo-dev-client": "~57.0.19", | |
| 31 | + | "expo-device": "~57.0.2", | |
| 32 | + | "expo-font": "~57.0.4", | |
| 33 | + | "expo-haptics": "~57.0.3", | |
| 34 | + | "expo-image": "~57.0.5", | |
| 35 | + | "expo-linking": "~57.0.12", | |
| 36 | + | "expo-notifications": "~57.0.22", | |
| 37 | + | "expo-router": "~57.0.25", | |
| 38 | + | "expo-secure-store": "~57.0.4", | |
| 39 | + | "expo-splash-screen": "~57.0.9", | |
| 40 | + | "expo-status-bar": "~57.0.1", | |
| 41 | + | "expo-system-ui": "~57.0.4", | |
| 42 | + | "expo-web-browser": "~57.0.3", | |
| 43 | + | "lucide-react-native": "^1.55.0", | |
| 44 | + | "react": "19.2.3", | |
| 45 | + | "react-native": "0.86.3", | |
| 46 | + | "react-native-gesture-handler": "~2.32.0", | |
| 47 | + | "react-native-reanimated": "4.5.1", | |
| 48 | + | "react-native-safe-area-context": "~5.7.0", | |
| 49 | + | "react-native-screens": "~4.26.0", | |
| 50 | + | "react-native-svg": "15.15.4", | |
| 51 | + | "react-native-worklets": "0.10.1" | |
| 52 | + | }, | |
| 53 | + | "devDependencies": { | |
| 54 | + | "@types/node": "^22.20.5", | |
| 55 | + | "@types/react": "~19.2.2", | |
| 56 | + | "sharp": "^0.35.5", | |
| 57 | + | "typescript": "~6.0.3" | |
| 58 | + | } | |
| 59 | + | } |
| 1 | + | // Draws the app's icons from the g1t mark (packages/theme/mark.svg): | |
| 2 | + | // `npm run icons -w @g1t/mobile` after the mark or the background changes. | |
| 3 | + | // | |
| 4 | + | // - icon.png: the store and iOS icon, the mark on g1t's background. | |
| 5 | + | // - android-icon-foreground.png: the adaptive icon's mark, inside the | |
| 6 | + | // middle 66% Android keeps whatever the launcher's mask. | |
| 7 | + | // - android-icon-monochrome.png: the same in white, for themed icons. | |
| 8 | + | // - splash-icon.png: the mark alone, on the splash screen's background. | |
| 9 | + | // - notification-icon.png: the mark in white on nothing, as Android draws | |
| 10 | + | // a notification's small icon (tinted with the accent). | |
| 11 | + | import { readFile } from "node:fs/promises"; | |
| 12 | + | import { fileURLToPath } from "node:url"; | |
| 13 | + | import sharp from "sharp"; | |
| 14 | + | ||
| 15 | + | const here = (path) => fileURLToPath(new URL(path, import.meta.url)); | |
| 16 | + | const mark = await readFile(here("../../../packages/theme/mark.svg"), "utf8"); | |
| 17 | + | const BACKGROUND = "#0f0f11"; | |
| 18 | + | ||
| 19 | + | /** The mark at `scale` of a `size` square, recoloured, on `background` or nothing. */ | |
| 20 | + | async function draw(file, { size, scale, color, background }) { | |
| 21 | + | const inner = Math.round(size * scale); | |
| 22 | + | const svg = Buffer.from(mark.replace(/#b6a8ff/g, color ?? "#b6a8ff").replace("<svg ", `<svg width="${inner}" height="${inner}" `)); | |
| 23 | + | const glyph = await sharp(svg, { density: 72 * (inner / 80) }).resize(inner, inner).png().toBuffer(); | |
| 24 | + | await sharp({ create: { width: size, height: size, channels: 4, background: background ?? { r: 0, g: 0, b: 0, alpha: 0 } } }) | |
| 25 | + | .composite([{ input: glyph, gravity: "centre" }]) | |
| 26 | + | .png() | |
| 27 | + | .toFile(here(`../assets/images/${file}`)); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | await draw("icon.png", { size: 1024, scale: 0.62, background: BACKGROUND }); | |
| 31 | + | await draw("android-icon-foreground.png", { size: 1024, scale: 0.46 }); | |
| 32 | + | await draw("android-icon-monochrome.png", { size: 1024, scale: 0.46, color: "#ffffff" }); | |
| 33 | + | await draw("splash-icon.png", { size: 512, scale: 0.9 }); | |
| 34 | + | await draw("notification-icon.png", { size: 96, scale: 0.8, color: "#ffffff" }); | |
| 35 | + | console.log("Drew icon.png, android-icon-foreground.png, android-icon-monochrome.png, splash-icon.png and notification-icon.png."); |
| 1 | + | import * as Notifications from "expo-notifications"; | |
| 2 | + | import { Tabs, router, useGlobalSearchParams, useSegments } from "expo-router"; | |
| 3 | + | import { Bell, Bot, Code2, House, Menu, MessagesSquare } from "lucide-react-native"; | |
| 4 | + | import type { LucideIcon } from "lucide-react-native"; | |
| 5 | + | import { type ComponentType, useEffect, useRef } from "react"; | |
| 6 | + | import { StyleSheet, View } from "react-native"; | |
| 7 | + | ||
| 8 | + | import { Banner } from "@/components/chat/banner"; | |
| 9 | + | import { FeedProvider, useFeed } from "@/lib/feed"; | |
| 10 | + | import { routeFor } from "@/lib/links"; | |
| 11 | + | import { type PushData, registerForPush } from "@/lib/push"; | |
| 12 | + | import { useSignedIn } from "@/lib/session"; | |
| 13 | + | import { hasCodeAccess } from "@g1t/contracts"; | |
| 14 | + | import { useColors } from "@/theme"; | |
| 15 | + | ||
| 16 | + | /** | |
| 17 | + | * The signed-in app. Its bar is the site's phone bar | |
| 18 | + | * (apps/web/app/components/mobile.tsx): Home, Chat, Notifications, Agents, | |
| 19 | + | * Code, and More. Chat and Code have their screens; the others say so and | |
| 20 | + | * open the site. Customize and your account are under More. | |
| 21 | + | */ | |
| 22 | + | export default function AppLayout() { | |
| 23 | + | return ( | |
| 24 | + | <FeedProvider> | |
| 25 | + | <PushSetup /> | |
| 26 | + | <AppTabs /> | |
| 27 | + | <Banner /> | |
| 28 | + | </FeedProvider> | |
| 29 | + | ); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | function AppTabs() { | |
| 33 | + | const colors = useColors(); | |
| 34 | + | const { workspace, selectWorkspace } = useSignedIn(); | |
| 35 | + | const { unread, mentions } = useFeed(); | |
| 36 | + | // A link names its workspace (`/chat?workspace=acme`, lib/links.ts): switch to it. | |
| 37 | + | const { workspace: linked } = useGlobalSearchParams<{ workspace?: string }>(); | |
| 38 | + | useEffect(() => { | |
| 39 | + | if (linked && linked.toLowerCase() !== workspace?.slug.toLowerCase()) void selectWorkspace(linked); | |
| 40 | + | }, [linked, workspace?.slug, selectWorkspace]); | |
| 41 | + | const code = !workspace || hasCodeAccess(workspace); | |
| 42 | + | // A conversation or a thread takes the whole screen, as on the site: the bar steps aside. | |
| 43 | + | const segments = useSegments() as string[]; | |
| 44 | + | const inConversation = segments[1] === "chat" && (segments[2] === "[channel]" || segments[2] === "thread"); | |
| 45 | + | const badge = mentions > 0 ? mentions : unread > 0 ? unread : undefined; | |
| 46 | + | ||
| 47 | + | return ( | |
| 48 | + | <Tabs | |
| 49 | + | initialRouteName="chat" | |
| 50 | + | screenOptions={{ | |
| 51 | + | headerShown: false, | |
| 52 | + | // The site's phone bar: flat along the bottom on the rail's colour, the | |
| 53 | + | // app you are in marked as the rail marks it (apps/web components/mobile.tsx). | |
| 54 | + | tabBarActiveTintColor: colors.fg, | |
| 55 | + | tabBarInactiveTintColor: colors.faint, | |
| 56 | + | tabBarStyle: inConversation | |
| 57 | + | ? { display: "none" } | |
| 58 | + | : { backgroundColor: colors.rail, borderTopColor: colors.line, borderTopWidth: StyleSheet.hairlineWidth, elevation: 0 }, | |
| 59 | + | tabBarHideOnKeyboard: true, | |
| 60 | + | tabBarLabelStyle: { fontSize: 11, fontWeight: "500" }, | |
| 61 | + | tabBarIconStyle: { width: 44, height: 28 }, | |
| 62 | + | }} | |
| 63 | + | > | |
| 64 | + | <Tabs.Screen name="index" options={{ href: null }} /> | |
| 65 | + | <Tabs.Screen name="home" options={{ title: "Home", tabBarIcon: (props) => <BarIcon icon={House} {...props} /> }} /> | |
| 66 | + | <Tabs.Screen | |
| 67 | + | name="chat" | |
| 68 | + | options={{ | |
| 69 | + | title: "Chat", | |
| 70 | + | tabBarIcon: (props) => <BarIcon icon={MessagesSquare} {...props} />, | |
| 71 | + | tabBarBadge: badge, | |
| 72 | + | tabBarBadgeStyle: { backgroundColor: mentions > 0 ? colors.accent : colors.lineStrong, color: mentions > 0 ? colors.onAccent : colors.fg, fontSize: 11 }, | |
| 73 | + | }} | |
| 74 | + | /> | |
| 75 | + | <Tabs.Screen name="notifications" options={{ title: "Notifications", tabBarIcon: (props) => <BarIcon icon={Bell} {...props} /> }} /> | |
| 76 | + | <Tabs.Screen name="agents" options={{ title: "Agents", tabBarIcon: (props) => <BarIcon icon={Bot} {...props} /> }} /> | |
| 77 | + | <Tabs.Screen name="code" options={{ title: "Code", href: code ? undefined : null, tabBarIcon: (props) => <BarIcon icon={Code2} {...props} /> }} /> | |
| 78 | + | <Tabs.Screen name="customize" options={{ href: null }} /> | |
| 79 | + | <Tabs.Screen name="more" options={{ title: "More", tabBarIcon: (props) => <BarIcon icon={Menu} {...props} /> }} /> | |
| 80 | + | </Tabs> | |
| 81 | + | ); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /** A tab's icon: on the rail's accent in a 10px-rounded square when it is the tab you are in. */ | |
| 85 | + | function BarIcon({ icon: Icon, focused }: { icon: LucideIcon | ComponentType<{ color: string; size: number }>; focused: boolean }) { | |
| 86 | + | const colors = useColors(); | |
| 87 | + | return ( | |
| 88 | + | <View style={{ width: 44, height: 28, borderRadius: 10, alignItems: "center", justifyContent: "center", backgroundColor: focused ? colors.railAccent : "transparent" }}> | |
| 89 | + | <Icon color={focused ? colors.fg : colors.muted} size={20} /> | |
| 90 | + | </View> | |
| 91 | + | ); | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /** | |
| 95 | + | * Registers the phone for notifications once signed in (asking the first | |
| 96 | + | * time), and opens what a tapped notification is about: the thread or | |
| 97 | + | * conversation, in its workspace. | |
| 98 | + | */ | |
| 99 | + | function PushSetup() { | |
| 100 | + | const { client, selectWorkspace } = useSignedIn(); | |
| 101 | + | const handled = useRef<string | null>(null); | |
| 102 | + | const response = Notifications.useLastNotificationResponse(); | |
| 103 | + | ||
| 104 | + | useEffect(() => { | |
| 105 | + | void registerForPush(client, true).catch((error: unknown) => console.warn("g1t: registering for notifications failed", error)); | |
| 106 | + | // The system can give the app a new push token: register that one too. | |
| 107 | + | const changed = Notifications.addPushTokenListener(() => { | |
| 108 | + | void registerForPush(client, false).catch(() => undefined); | |
| 109 | + | }); | |
| 110 | + | return () => changed.remove(); | |
| 111 | + | }, [client]); | |
| 112 | + | ||
| 113 | + | useEffect(() => { | |
| 114 | + | if (!response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return; | |
| 115 | + | const id = response.notification.request.identifier; | |
| 116 | + | if (handled.current === id) return; | |
| 117 | + | handled.current = id; | |
| 118 | + | const data = (response.notification.request.content.data ?? {}) as PushData; | |
| 119 | + | void (async () => { | |
| 120 | + | if (data.workspace) await selectWorkspace(data.workspace); | |
| 121 | + | if (data.channel_id && data.thread_root) { | |
| 122 | + | router.push({ pathname: "/chat/thread", params: { channel: data.channel_id, root: data.thread_root } }); | |
| 123 | + | } else if (data.channel_id) { | |
| 124 | + | router.push({ pathname: "/chat/[channel]", params: { channel: data.channel_id } }); | |
| 125 | + | } else if (data.href) { | |
| 126 | + | const route = routeFor(data.href); | |
| 127 | + | router.push((route ?? `/open?path=${encodeURIComponent(data.href)}`) as never); | |
| 128 | + | } | |
| 129 | + | })(); | |
| 130 | + | }, [response, selectWorkspace]); | |
| 131 | + | ||
| 132 | + | return null; | |
| 133 | + | } |
| 1 | + | import { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 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 | + | } |
| 1 | + | import { Stack, router, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import { BellOff, Bell, LogOut, MoreHorizontal, Star, StarOff } from "lucide-react-native"; | |
| 3 | + | import { useState } from "react"; | |
| 4 | + | import { Alert, Pressable, View } from "react-native"; | |
| 5 | + | ||
| 6 | + | import { Conversation } from "@/components/chat/conversation"; | |
| 7 | + | import { Sheet, SheetAction } from "@/components/chat/sheet"; | |
| 8 | + | import { Empty, Loading, Screen, Text } from "@/components/ui"; | |
| 9 | + | import { useChatActions, useChatSidebar } from "@/lib/chat"; | |
| 10 | + | import { space, useColors } from "@/theme"; | |
| 11 | + | ||
| 12 | + | /** | |
| 13 | + | * A conversation. `channel` is a channel's id, or a channel's name when a | |
| 14 | + | * link named it (`/acme/-/chat/general`). The ⋯ button stars, mutes or | |
| 15 | + | * leaves it. | |
| 16 | + | */ | |
| 17 | + | export default function ConversationScreen() { | |
| 18 | + | const params = useLocalSearchParams<{ channel: string }>(); | |
| 19 | + | const sidebar = useChatSidebar(); | |
| 20 | + | const colors = useColors(); | |
| 21 | + | const [menu, setMenu] = useState(false); | |
| 22 | + | const entry = sidebar.data?.entries.find((one) => one.channel.id === params.channel || one.channel.name === params.channel); | |
| 23 | + | // A link by name waits for the sidebar to say which channel it is. | |
| 24 | + | const channelId = entry?.channel.id ?? (/^[a-z]+_/.test(params.channel) ? params.channel : ""); | |
| 25 | + | const actions = useChatActions(channelId); | |
| 26 | + | const dm = entry?.channel.kind === "dm"; | |
| 27 | + | const title = entry ? (dm ? entry.title : `#${entry.channel.name}`) : ""; | |
| 28 | + | ||
| 29 | + | if (!channelId) { | |
| 30 | + | return ( | |
| 31 | + | <Screen> | |
| 32 | + | <Stack.Screen options={{ title: "" }} /> | |
| 33 | + | {sidebar.isPending ? <Loading /> : <Empty title="Not in this conversation" body="It may be private, or you may need to join it first." />} | |
| 34 | + | </Screen> | |
| 35 | + | ); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | return ( | |
| 39 | + | <Screen> | |
| 40 | + | <Stack.Screen | |
| 41 | + | options={{ | |
| 42 | + | headerTitle: () => ( | |
| 43 | + | <View> | |
| 44 | + | <Text style={{ fontWeight: "700" }} numberOfLines={1}> | |
| 45 | + | {title} | |
| 46 | + | </Text> | |
| 47 | + | {entry?.channel.topic ? ( | |
| 48 | + | <Text size="small" tone="muted" numberOfLines={1}> | |
| 49 | + | {entry.channel.topic} | |
| 50 | + | </Text> | |
| 51 | + | ) : null} | |
| 52 | + | </View> | |
| 53 | + | ), | |
| 54 | + | headerRight: () => | |
| 55 | + | entry ? ( | |
| 56 | + | <Pressable accessibilityLabel="Conversation options" hitSlop={10} onPress={() => setMenu(true)}> | |
| 57 | + | <MoreHorizontal size={22} color={colors.fg} /> | |
| 58 | + | </Pressable> | |
| 59 | + | ) : null, | |
| 60 | + | }} | |
| 61 | + | /> | |
| 62 | + | <Conversation channelId={channelId} thread={null} title={title || "this conversation"} /> | |
| 63 | + | <Sheet visible={menu} onClose={() => setMenu(false)}> | |
| 64 | + | <View style={{ paddingHorizontal: space.lg, paddingBottom: space.sm }}> | |
| 65 | + | <Text size="heading">{title}</Text> | |
| 66 | + | </View> | |
| 67 | + | <SheetAction | |
| 68 | + | icon={entry?.starred ? StarOff : Star} | |
| 69 | + | label={entry?.starred ? "Unstar" : "Star"} | |
| 70 | + | onPress={() => { | |
| 71 | + | setMenu(false); | |
| 72 | + | actions.preferences.mutate({ starred: !entry?.starred }); | |
| 73 | + | }} | |
| 74 | + | /> | |
| 75 | + | <SheetAction | |
| 76 | + | icon={entry?.muted ? Bell : BellOff} | |
| 77 | + | label={entry?.muted ? "Unmute" : "Mute"} | |
| 78 | + | onPress={() => { | |
| 79 | + | setMenu(false); | |
| 80 | + | actions.preferences.mutate({ muted: !entry?.muted }); | |
| 81 | + | }} | |
| 82 | + | /> | |
| 83 | + | {!dm ? ( | |
| 84 | + | <SheetAction | |
| 85 | + | icon={LogOut} | |
| 86 | + | label="Leave channel" | |
| 87 | + | danger | |
| 88 | + | onPress={() => { | |
| 89 | + | setMenu(false); | |
| 90 | + | Alert.alert(`Leave ${title}?`, "You can join it again from Browse.", [ | |
| 91 | + | { text: "Cancel", style: "cancel" }, | |
| 92 | + | { text: "Leave", style: "destructive", onPress: () => actions.leave.mutate(undefined, { onSuccess: () => router.back() }) }, | |
| 93 | + | ]); | |
| 94 | + | }} | |
| 95 | + | /> | |
| 96 | + | ) : null} | |
| 97 | + | </Sheet> | |
| 98 | + | </Screen> | |
| 99 | + | ); | |
| 100 | + | } |
| 1 | + | import { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 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 | + | } |
| 1 | + | import type { ChatSidebarEntry } from "@g1t/contracts"; | |
| 2 | + | import { Stack, router } from "expo-router"; | |
| 3 | + | import { Hash, Lock, MessagesSquare, Search, SquarePen } from "lucide-react-native"; | |
| 4 | + | import { useMemo, useState } from "react"; | |
| 5 | + | import { Pressable, RefreshControl, SectionList, StyleSheet, TextInput, View } from "react-native"; | |
| 6 | + | ||
| 7 | + | import { Avatar, Badge, Button, Empty, ErrorView, Loading, Screen, SectionHeader, Text } from "@/components/ui"; | |
| 8 | + | import { useChatSidebar } from "@/lib/chat"; | |
| 9 | + | import { useFeed } from "@/lib/feed"; | |
| 10 | + | import { useSignedIn } from "@/lib/session"; | |
| 11 | + | import { ago } from "@/lib/time"; | |
| 12 | + | import { radius, space, useColors } from "@/theme"; | |
| 13 | + | ||
| 14 | + | /** | |
| 15 | + | * Chat: starred conversations, the channels you are in, and your direct | |
| 16 | + | * messages, with what is unread. Counts move live with the feed | |
| 17 | + | * (lib/feed.tsx); pull down to read everything again. | |
| 18 | + | */ | |
| 19 | + | export default function ChatList() { | |
| 20 | + | const { workspace } = useSignedIn(); | |
| 21 | + | const sidebar = useChatSidebar(); | |
| 22 | + | const colors = useColors(); | |
| 23 | + | const [search, setSearch] = useState(""); | |
| 24 | + | ||
| 25 | + | const sections = useMemo(() => { | |
| 26 | + | const words = search.trim().toLowerCase(); | |
| 27 | + | const entries = (sidebar.data?.entries ?? []).filter( | |
| 28 | + | (entry) => !entry.channel.archived_at && (!words || `${entry.title} ${entry.channel.name ?? ""}`.toLowerCase().includes(words)), | |
| 29 | + | ); | |
| 30 | + | const byRecent = (a: ChatSidebarEntry, b: ChatSidebarEntry) => (b.channel.last_message_at ?? "").localeCompare(a.channel.last_message_at ?? ""); | |
| 31 | + | return [ | |
| 32 | + | { title: "Starred", data: entries.filter((entry) => entry.starred).sort(byRecent) }, | |
| 33 | + | { title: "Channels", data: entries.filter((entry) => !entry.starred && entry.channel.kind === "channel").sort((a, b) => (a.channel.name ?? "").localeCompare(b.channel.name ?? "")) }, | |
| 34 | + | { title: "Direct messages", data: entries.filter((entry) => !entry.starred && entry.channel.kind === "dm").sort(byRecent) }, | |
| 35 | + | ].filter((section) => section.data.length > 0); | |
| 36 | + | }, [sidebar.data, search]); | |
| 37 | + | ||
| 38 | + | const header = ( | |
| 39 | + | <Stack.Screen | |
| 40 | + | options={{ | |
| 41 | + | title: workspace?.name ?? workspace?.slug ?? "Chat", | |
| 42 | + | headerRight: () => ( | |
| 43 | + | <Pressable accessibilityLabel="New message" hitSlop={10} onPress={() => router.push("/chat/new")}> | |
| 44 | + | <SquarePen size={22} color={colors.fg} /> | |
| 45 | + | </Pressable> | |
| 46 | + | ), | |
| 47 | + | }} | |
| 48 | + | /> | |
| 49 | + | ); | |
| 50 | + | ||
| 51 | + | if (!workspace) { | |
| 52 | + | return ( | |
| 53 | + | <Screen> | |
| 54 | + | {header} | |
| 55 | + | <Empty icon={MessagesSquare} title="No workspace yet" body="Create or join a workspace on g1t, then come back." /> | |
| 56 | + | </Screen> | |
| 57 | + | ); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | return ( | |
| 61 | + | <Screen> | |
| 62 | + | {header} | |
| 63 | + | {sidebar.isPending ? ( | |
| 64 | + | <Loading /> | |
| 65 | + | ) : sidebar.error ? ( | |
| 66 | + | <ErrorView error={sidebar.error} retry={() => sidebar.refetch()} /> | |
| 67 | + | ) : ( | |
| 68 | + | <SectionList | |
| 69 | + | sections={sections} | |
| 70 | + | keyExtractor={(entry) => entry.channel.id} | |
| 71 | + | stickySectionHeadersEnabled={false} | |
| 72 | + | keyboardShouldPersistTaps="handled" | |
| 73 | + | ListHeaderComponent={ | |
| 74 | + | <View style={[styles.search, { backgroundColor: colors.raised }]}> | |
| 75 | + | <Search size={16} color={colors.faint} /> | |
| 76 | + | <TextInput | |
| 77 | + | value={search} | |
| 78 | + | onChangeText={setSearch} | |
| 79 | + | placeholder="Find a conversation" | |
| 80 | + | placeholderTextColor={colors.faint} | |
| 81 | + | style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: 8 }} | |
| 82 | + | autoCorrect={false} | |
| 83 | + | autoCapitalize="none" | |
| 84 | + | /> | |
| 85 | + | </View> | |
| 86 | + | } | |
| 87 | + | renderSectionHeader={({ section }) => <SectionHeader title={section.title} />} | |
| 88 | + | renderItem={({ item }) => <Conversation entry={item} />} | |
| 89 | + | refreshControl={<RefreshControl refreshing={sidebar.isRefetching} onRefresh={() => sidebar.refetch()} tintColor={colors.accent} />} | |
| 90 | + | ListEmptyComponent={ | |
| 91 | + | search ? ( | |
| 92 | + | <Empty title="Nothing matches" /> | |
| 93 | + | ) : ( | |
| 94 | + | <Empty icon={MessagesSquare} title="No conversations yet" body="Message a person or an agent, or join a channel." action={<Button title="New message" onPress={() => router.push("/chat/new")} />} /> | |
| 95 | + | ) | |
| 96 | + | } | |
| 97 | + | ListFooterComponent={ | |
| 98 | + | sidebar.data && sidebar.data.browsable > 0 && !search ? ( | |
| 99 | + | <Pressable onPress={() => router.push("/chat/browse")} style={styles.browse}> | |
| 100 | + | <Hash size={16} color={colors.accent} /> | |
| 101 | + | <Text tone="accent" style={{ fontWeight: "600" }}> | |
| 102 | + | Browse {sidebar.data.browsable} more {sidebar.data.browsable === 1 ? "channel" : "channels"} | |
| 103 | + | </Text> | |
| 104 | + | </Pressable> | |
| 105 | + | ) : ( | |
| 106 | + | <View style={{ height: space.xl }} /> | |
| 107 | + | ) | |
| 108 | + | } | |
| 109 | + | /> | |
| 110 | + | )} | |
| 111 | + | </Screen> | |
| 112 | + | ); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | function Conversation({ entry }: { entry: ChatSidebarEntry }) { | |
| 116 | + | const colors = useColors(); | |
| 117 | + | const { server } = useSignedIn(); | |
| 118 | + | const { presence } = useFeed(); | |
| 119 | + | const { channel } = entry; | |
| 120 | + | const dm = channel.kind === "dm"; | |
| 121 | + | const other = entry.others[0]; | |
| 122 | + | const loud = entry.unread > 0 && !entry.muted; | |
| 123 | + | const online = dm && other && other.kind === "user" && presence.get(other.id) === "active"; | |
| 124 | + | const title = dm ? entry.title : (channel.name ?? entry.title); | |
| 125 | + | ||
| 126 | + | return ( | |
| 127 | + | <Pressable | |
| 128 | + | onPress={() => router.push({ pathname: "/chat/[channel]", params: { channel: channel.id } })} | |
| 129 | + | style={({ pressed }) => [styles.row, { backgroundColor: pressed ? colors.raised : "transparent" }]} | |
| 130 | + | > | |
| 131 | + | <View> | |
| 132 | + | {dm && other ? ( | |
| 133 | + | <Avatar name={other.display_name} uri={other.avatar ? `${server.site}/avatars/${other.avatar}` : null} agent={other.kind === "agent"} size={40} /> | |
| 134 | + | ) : ( | |
| 135 | + | <View style={[styles.icon, { backgroundColor: colors.raised }]}> | |
| 136 | + | {channel.private ? <Lock size={18} color={colors.muted} /> : <Hash size={18} color={colors.muted} />} | |
| 137 | + | </View> | |
| 138 | + | )} | |
| 139 | + | {online ? <View style={[styles.online, { backgroundColor: colors.success, borderColor: colors.bg }]} /> : null} | |
| 140 | + | </View> | |
| 141 | + | <View style={{ flex: 1, gap: 2 }}> | |
| 142 | + | <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}> | |
| 143 | + | <Text numberOfLines={1} style={{ flex: 1, fontWeight: loud ? "800" : "500", color: entry.muted ? colors.muted : colors.fg }}> | |
| 144 | + | {title} | |
| 145 | + | </Text> | |
| 146 | + | {channel.last_message_at ? ( | |
| 147 | + | <Text size="small" tone="faint"> | |
| 148 | + | {ago(channel.last_message_at)} | |
| 149 | + | </Text> | |
| 150 | + | ) : null} | |
| 151 | + | </View> | |
| 152 | + | <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}> | |
| 153 | + | <Text size="small" tone="muted" numberOfLines={1} style={{ flex: 1 }}> | |
| 154 | + | {dm ? (other?.kind === "agent" ? (other.title ?? other.role ?? "Agent") : `@${other?.name ?? ""}`) : (channel.topic ?? (entry.muted ? "Muted" : " "))} | |
| 155 | + | </Text> | |
| 156 | + | {entry.mentions > 0 ? <Badge label={`@ ${entry.mentions}`} tone="accent" /> : loud ? <View style={[styles.dot, { backgroundColor: colors.accent }]} /> : null} | |
| 157 | + | </View> | |
| 158 | + | </View> | |
| 159 | + | </Pressable> | |
| 160 | + | ); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | const styles = StyleSheet.create({ | |
| 164 | + | search: { flexDirection: "row", alignItems: "center", gap: space.sm, marginHorizontal: space.lg, marginTop: space.md, paddingHorizontal: space.md, borderRadius: radius.md }, | |
| 165 | + | row: { flexDirection: "row", alignItems: "center", gap: space.md, paddingHorizontal: space.lg, paddingVertical: 10 }, | |
| 166 | + | icon: { width: 40, height: 40, borderRadius: radius.md, alignItems: "center", justifyContent: "center" }, | |
| 167 | + | online: { position: "absolute", right: -1, bottom: -1, width: 12, height: 12, borderRadius: 6, borderWidth: 2 }, | |
| 168 | + | dot: { width: 9, height: 9, borderRadius: 5 }, | |
| 169 | + | browse: { flexDirection: "row", alignItems: "center", gap: space.sm, padding: space.lg }, | |
| 170 | + | }); |
| 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 | + | }); |
| 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 | + | } |
| 1 | + | import { Stack, router, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import { CircleDot, GitPullRequest } from "lucide-react-native"; | |
| 3 | + | import { useState } from "react"; | |
| 4 | + | import { FlatList, RefreshControl, View } from "react-native"; | |
| 5 | + | ||
| 6 | + | import { Checks, IssueIcon, PullIcon } from "@/components/code"; | |
| 7 | + | import { Segments } from "@/components/segments"; | |
| 8 | + | import { Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui"; | |
| 9 | + | import { type ApiIssue, type ApiPull, useIssues, usePulls, useRepo } from "@/lib/code"; | |
| 10 | + | import { ago } from "@/lib/time"; | |
| 11 | + | import { space, useColors } from "@/theme"; | |
| 12 | + | ||
| 13 | + | type Tab = "pulls" | "issues"; | |
| 14 | + | type State = "open" | "closed"; | |
| 15 | + | ||
| 16 | + | /** A repository: its pull requests and issues. */ | |
| 17 | + | export default function Repository() { | |
| 18 | + | const { owner, repo } = useLocalSearchParams<{ owner: string; repo: string }>(); | |
| 19 | + | const [tab, setTab] = useState<Tab>("pulls"); | |
| 20 | + | const [state, setState] = useState<State>("open"); | |
| 21 | + | const details = useRepo(owner, repo); | |
| 22 | + | const pulls = usePulls(owner, repo, state); | |
| 23 | + | const issues = useIssues(owner, repo, state); | |
| 24 | + | const list = tab === "pulls" ? pulls : issues; | |
| 25 | + | const colors = useColors(); | |
| 26 | + | ||
| 27 | + | return ( | |
| 28 | + | <Screen> | |
| 29 | + | <Stack.Screen options={{ title: repo }} /> | |
| 30 | + | <View style={{ padding: space.lg, gap: space.md }}> | |
| 31 | + | <Text size="small" tone="muted"> | |
| 32 | + | {owner}/{repo} | |
| 33 | + | {details.data?.description ? ` · ${details.data.description}` : ""} | |
| 34 | + | </Text> | |
| 35 | + | <Segments | |
| 36 | + | value={tab} | |
| 37 | + | onChange={setTab} | |
| 38 | + | options={[ | |
| 39 | + | { value: "pulls", label: "Pull requests" }, | |
| 40 | + | { value: "issues", label: "Issues" }, | |
| 41 | + | ]} | |
| 42 | + | /> | |
| 43 | + | <Segments | |
| 44 | + | value={state} | |
| 45 | + | onChange={setState} | |
| 46 | + | options={[ | |
| 47 | + | { value: "open", label: "Open" }, | |
| 48 | + | { value: "closed", label: "Closed" }, | |
| 49 | + | ]} | |
| 50 | + | /> | |
| 51 | + | </View> | |
| 52 | + | {list.isPending ? ( | |
| 53 | + | <Loading /> | |
| 54 | + | ) : list.error ? ( | |
| 55 | + | <ErrorView error={list.error} retry={() => list.refetch()} /> | |
| 56 | + | ) : tab === "pulls" ? ( | |
| 57 | + | <FlatList | |
| 58 | + | data={pulls.data} | |
| 59 | + | keyExtractor={(pull) => pull.id} | |
| 60 | + | refreshControl={<RefreshControl refreshing={pulls.isRefetching} onRefresh={() => pulls.refetch()} tintColor={colors.accent} />} | |
| 61 | + | ListEmptyComponent={<Empty icon={GitPullRequest} title={`No ${state} pull requests`} />} | |
| 62 | + | renderItem={({ item }) => <PullRow pull={item} owner={owner} repo={repo} />} | |
| 63 | + | /> | |
| 64 | + | ) : ( | |
| 65 | + | <FlatList | |
| 66 | + | data={issues.data} | |
| 67 | + | keyExtractor={(issue) => issue.id} | |
| 68 | + | refreshControl={<RefreshControl refreshing={issues.isRefetching} onRefresh={() => issues.refetch()} tintColor={colors.accent} />} | |
| 69 | + | ListEmptyComponent={<Empty icon={CircleDot} title={`No ${state} issues`} />} | |
| 70 | + | renderItem={({ item }) => <IssueRow issue={item} owner={owner} repo={repo} />} | |
| 71 | + | /> | |
| 72 | + | )} | |
| 73 | + | </Screen> | |
| 74 | + | ); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | function PullRow({ pull, owner, repo }: { pull: ApiPull; owner: string; repo: string }) { | |
| 78 | + | const by = pull.author.display_username ?? pull.author.username; | |
| 79 | + | return ( | |
| 80 | + | <Row | |
| 81 | + | leading={<PullIcon pull={pull} />} | |
| 82 | + | title={pull.title} | |
| 83 | + | subtitle={`#${pull.number} · ${by}${pull.agent ? ` with ${pull.agent}` : ""} · ${ago(pull.updated_at)}`} | |
| 84 | + | trailing={<Checks status={pull.check_status} />} | |
| 85 | + | onPress={() => router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo, number: String(pull.number) } })} | |
| 86 | + | /> | |
| 87 | + | ); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | function IssueRow({ issue, owner, repo }: { issue: ApiIssue; owner: string; repo: string }) { | |
| 91 | + | const by = issue.author.display_username ?? issue.author.username; | |
| 92 | + | return ( | |
| 93 | + | <Row | |
| 94 | + | leading={<IssueIcon issue={issue} />} | |
| 95 | + | title={issue.title} | |
| 96 | + | subtitle={`#${issue.number} · ${by} · ${ago(issue.updated_at)}${issue.comment_count ? ` · ${issue.comment_count} comments` : ""}`} | |
| 97 | + | onPress={() => router.push({ pathname: "/code/[owner]/[repo]/issues/[number]", params: { owner, repo, number: String(issue.number) } })} | |
| 98 | + | /> | |
| 99 | + | ); | |
| 100 | + | } |
| 1 | + | import { Stack, router, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import * as WebBrowser from "expo-web-browser"; | |
| 3 | + | import { ExternalLink, Send } from "lucide-react-native"; | |
| 4 | + | import { useState } from "react"; | |
| 5 | + | import { View } from "react-native"; | |
| 6 | + | ||
| 7 | + | import { Body, IssueIcon, PullIcon, Timeline } from "@/components/code"; | |
| 8 | + | import { Button, ErrorView, Field, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| 9 | + | import { useComment, useIssue } from "@/lib/code"; | |
| 10 | + | import { siteUrl } from "@/lib/server"; | |
| 11 | + | import { useSignedIn } from "@/lib/session"; | |
| 12 | + | import { ago } from "@/lib/time"; | |
| 13 | + | import { space } from "@/theme"; | |
| 14 | + | ||
| 15 | + | /** An issue: what it asks for, the pull requests made for it, and its conversation. */ | |
| 16 | + | export default function Issue() { | |
| 17 | + | const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>(); | |
| 18 | + | const number = Number(params.number); | |
| 19 | + | const { server } = useSignedIn(); | |
| 20 | + | const issue = useIssue(params.owner, params.repo, number); | |
| 21 | + | const comment = useComment(params.owner, params.repo, number); | |
| 22 | + | const [draft, setDraft] = useState(""); | |
| 23 | + | ||
| 24 | + | if (issue.isPending || issue.error) { | |
| 25 | + | return ( | |
| 26 | + | <Screen> | |
| 27 | + | <Stack.Screen options={{ title: `#${number}` }} /> | |
| 28 | + | {issue.error ? <ErrorView error={issue.error} retry={() => issue.refetch()} /> : <Loading />} | |
| 29 | + | </Screen> | |
| 30 | + | ); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | const { issue: item, pulls, comments } = issue.data; | |
| 34 | + | const by = item.author.display_username ?? item.author.username; | |
| 35 | + | ||
| 36 | + | return ( | |
| 37 | + | <Screen scroll> | |
| 38 | + | <Stack.Screen options={{ title: `#${number}` }} /> | |
| 39 | + | <View style={{ gap: space.sm }}> | |
| 40 | + | <Text size="heading">{item.title}</Text> | |
| 41 | + | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}> | |
| 42 | + | <IssueIcon issue={item} /> | |
| 43 | + | <Text size="small" tone="muted"> | |
| 44 | + | {item.state === "open" ? "Open" : "Closed"} · {by} · {ago(item.created_at)} | |
| 45 | + | {item.labels.length ? ` · ${item.labels.join(", ")}` : ""} | |
| 46 | + | </Text> | |
| 47 | + | </View> | |
| 48 | + | </View> | |
| 49 | + | <Body text={item.body} /> | |
| 50 | + | {pulls.length ? ( | |
| 51 | + | <View style={{ marginHorizontal: -space.lg }}> | |
| 52 | + | <SectionHeader title="Pull requests" /> | |
| 53 | + | {pulls.map((pull) => ( | |
| 54 | + | <Row | |
| 55 | + | key={pull.id} | |
| 56 | + | leading={<PullIcon pull={pull} />} | |
| 57 | + | title={pull.title} | |
| 58 | + | subtitle={`#${pull.number} · ${pull.status}`} | |
| 59 | + | onPress={() => | |
| 60 | + | router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner: params.owner, repo: params.repo, number: String(pull.number) } }) | |
| 61 | + | } | |
| 62 | + | /> | |
| 63 | + | ))} | |
| 64 | + | </View> | |
| 65 | + | ) : null} | |
| 66 | + | <Timeline comments={comments} /> | |
| 67 | + | <View style={{ gap: space.sm }}> | |
| 68 | + | <Field label="Comment" placeholder="Add to the conversation" value={draft} onChangeText={setDraft} multiline /> | |
| 69 | + | <Button | |
| 70 | + | title="Comment" | |
| 71 | + | icon={Send} | |
| 72 | + | busy={comment.isPending} | |
| 73 | + | disabled={!draft.trim()} | |
| 74 | + | onPress={() => comment.mutate(draft.trim(), { onSuccess: () => setDraft("") })} | |
| 75 | + | /> | |
| 76 | + | {comment.error ? <Text tone="danger">{comment.error.message}</Text> : null} | |
| 77 | + | </View> | |
| 78 | + | <Button | |
| 79 | + | title="Open on g1t" | |
| 80 | + | variant="secondary" | |
| 81 | + | icon={ExternalLink} | |
| 82 | + | onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/issues/${number}`))} | |
| 83 | + | /> | |
| 84 | + | </Screen> | |
| 85 | + | ); | |
| 86 | + | } |
| 1 | + | import { Stack, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import * as WebBrowser from "expo-web-browser"; | |
| 3 | + | import { Check, ExternalLink, MessageSquareWarning } from "lucide-react-native"; | |
| 4 | + | import { useState } from "react"; | |
| 5 | + | import { View } from "react-native"; | |
| 6 | + | ||
| 7 | + | import { Body, Checks, Diff, StateBadge, Timeline } from "@/components/code"; | |
| 8 | + | import { Segments } from "@/components/segments"; | |
| 9 | + | import { Button, ErrorView, Field, Loading, Screen, Text } from "@/components/ui"; | |
| 10 | + | import { usePull, usePullChanges, useReview } from "@/lib/code"; | |
| 11 | + | import { siteUrl } from "@/lib/server"; | |
| 12 | + | import { useSignedIn } from "@/lib/session"; | |
| 13 | + | import { ago } from "@/lib/time"; | |
| 14 | + | import { space } from "@/theme"; | |
| 15 | + | ||
| 16 | + | /** | |
| 17 | + | * A pull request: what it is for, its checks, the conversation, and what | |
| 18 | + | * it changes; approve it or ask for changes from here. Merging stays on | |
| 19 | + | * the site for now, where its rules and the merge queue are shown. | |
| 20 | + | */ | |
| 21 | + | export default function PullRequest() { | |
| 22 | + | const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>(); | |
| 23 | + | const number = Number(params.number); | |
| 24 | + | const { server } = useSignedIn(); | |
| 25 | + | const [tab, setTab] = useState<"conversation" | "files">("conversation"); | |
| 26 | + | const pull = usePull(params.owner, params.repo, number); | |
| 27 | + | const changes = usePullChanges(params.owner, params.repo, number, tab === "files"); | |
| 28 | + | const review = useReview(params.owner, params.repo, number); | |
| 29 | + | const [note, setNote] = useState(""); | |
| 30 | + | ||
| 31 | + | const open = () => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/pull/${number}`)); | |
| 32 | + | ||
| 33 | + | if (pull.isPending) { | |
| 34 | + | return ( | |
| 35 | + | <Screen> | |
| 36 | + | <Stack.Screen options={{ title: `#${number}` }} /> | |
| 37 | + | <Loading /> | |
| 38 | + | </Screen> | |
| 39 | + | ); | |
| 40 | + | } | |
| 41 | + | if (pull.error) { | |
| 42 | + | return ( | |
| 43 | + | <Screen> | |
| 44 | + | <Stack.Screen options={{ title: `#${number}` }} /> | |
| 45 | + | <ErrorView error={pull.error} retry={() => pull.refetch()} /> | |
| 46 | + | </Screen> | |
| 47 | + | ); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | const { pull: pr, comments } = pull.data; | |
| 51 | + | const by = pr.author.display_username ?? pr.author.username; | |
| 52 | + | const reviewable = pr.status === "open"; | |
| 53 | + | ||
| 54 | + | return ( | |
| 55 | + | <Screen scroll> | |
| 56 | + | <Stack.Screen options={{ title: `#${number}` }} /> | |
| 57 | + | <View style={{ gap: space.sm }}> | |
| 58 | + | <Text size="heading">{pr.title}</Text> | |
| 59 | + | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center", flexWrap: "wrap" }}> | |
| 60 | + | <StateBadge pull={pr} /> | |
| 61 | + | <Checks status={pr.check_status} /> | |
| 62 | + | <Text size="small" tone="muted"> | |
| 63 | + | {by} | |
| 64 | + | {pr.agent ? ` with ${pr.agent}` : ""} · {pr.branch ?? "a fork"} → {pr.base} · {ago(pr.updated_at)} | |
| 65 | + | </Text> | |
| 66 | + | </View> | |
| 67 | + | </View> | |
| 68 | + | ||
| 69 | + | <Segments | |
| 70 | + | value={tab} | |
| 71 | + | onChange={setTab} | |
| 72 | + | options={[ | |
| 73 | + | { value: "conversation", label: "Conversation" }, | |
| 74 | + | { value: "files", label: `Files · ${pr.files.length}` }, | |
| 75 | + | ]} | |
| 76 | + | /> | |
| 77 | + | ||
| 78 | + | {tab === "conversation" ? ( | |
| 79 | + | <> | |
| 80 | + | <Body text={pr.body} /> | |
| 81 | + | <Timeline comments={comments} /> | |
| 82 | + | {reviewable ? ( | |
| 83 | + | <View style={{ gap: space.md }}> | |
| 84 | + | <Field label="Review" placeholder="Say what you think (optional to approve)" value={note} onChangeText={setNote} multiline /> | |
| 85 | + | <View style={{ flexDirection: "row", gap: space.sm }}> | |
| 86 | + | <View style={{ flex: 1 }}> | |
| 87 | + | <Button | |
| 88 | + | title="Approve" | |
| 89 | + | icon={Check} | |
| 90 | + | busy={review.isPending && review.variables?.verdict === "approve"} | |
| 91 | + | disabled={review.isPending} | |
| 92 | + | onPress={() => review.mutate({ verdict: "approve", body: note.trim() }, { onSuccess: () => setNote("") })} | |
| 93 | + | /> | |
| 94 | + | </View> | |
| 95 | + | <View style={{ flex: 1 }}> | |
| 96 | + | <Button | |
| 97 | + | title="Request changes" | |
| 98 | + | variant="secondary" | |
| 99 | + | icon={MessageSquareWarning} | |
| 100 | + | busy={review.isPending && review.variables?.verdict === "request_changes"} | |
| 101 | + | disabled={review.isPending || !note.trim()} | |
| 102 | + | onPress={() => review.mutate({ verdict: "request_changes", body: note.trim() }, { onSuccess: () => setNote("") })} | |
| 103 | + | /> | |
| 104 | + | </View> | |
| 105 | + | </View> | |
| 106 | + | {review.error ? <Text tone="danger">{review.error.message}</Text> : null} | |
| 107 | + | </View> | |
| 108 | + | ) : null} | |
| 109 | + | </> | |
| 110 | + | ) : changes.isPending ? ( | |
| 111 | + | <Loading /> | |
| 112 | + | ) : changes.error ? ( | |
| 113 | + | <ErrorView error={changes.error} retry={() => changes.refetch()} /> | |
| 114 | + | ) : ( | |
| 115 | + | <Diff changes={changes.data} /> | |
| 116 | + | )} | |
| 117 | + | ||
| 118 | + | <Button title="Open on g1t" variant="secondary" icon={ExternalLink} onPress={open} /> | |
| 119 | + | </Screen> | |
| 120 | + | ); | |
| 121 | + | } |
| 1 | + | import { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 1 | + | import { hasCodeAccess } from "@g1t/contracts"; | |
| 2 | + | import { Stack, router } from "expo-router"; | |
| 3 | + | import { BookLock, BookMarked, Code2 } from "lucide-react-native"; | |
| 4 | + | import { useState } from "react"; | |
| 5 | + | import { FlatList, RefreshControl, View } from "react-native"; | |
| 6 | + | ||
| 7 | + | import { Badge, Empty, ErrorView, Field, Loading, Row, Screen } from "@/components/ui"; | |
| 8 | + | import { useRepos } from "@/lib/code"; | |
| 9 | + | import { useSignedIn } from "@/lib/session"; | |
| 10 | + | import { ago } from "@/lib/time"; | |
| 11 | + | import { space, useColors } from "@/theme"; | |
| 12 | + | ||
| 13 | + | /** Code: the workspace's repositories you can see, newest first. */ | |
| 14 | + | export default function Repos() { | |
| 15 | + | const { workspace } = useSignedIn(); | |
| 16 | + | const [search, setSearch] = useState(""); | |
| 17 | + | const repos = useRepos(search.trim()); | |
| 18 | + | const colors = useColors(); | |
| 19 | + | ||
| 20 | + | if (workspace && !hasCodeAccess(workspace)) { | |
| 21 | + | return ( | |
| 22 | + | <Screen> | |
| 23 | + | <Stack.Screen options={{ title: "Code" }} /> | |
| 24 | + | <Empty icon={Code2} title="Code is not on for you here" body={`${workspace.name ?? workspace.slug} gives you Chat, Docs and agents. Ask an owner for Code.`} /> | |
| 25 | + | </Screen> | |
| 26 | + | ); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | return ( | |
| 30 | + | <Screen> | |
| 31 | + | <Stack.Screen options={{ title: "Code" }} /> | |
| 32 | + | <View style={{ padding: space.lg, paddingBottom: space.sm }}> | |
| 33 | + | <Field label="Find a repository" placeholder="Name or description" value={search} onChangeText={setSearch} returnKeyType="search" /> | |
| 34 | + | </View> | |
| 35 | + | {repos.isPending ? ( | |
| 36 | + | <Loading /> | |
| 37 | + | ) : repos.error ? ( | |
| 38 | + | <ErrorView error={repos.error} retry={() => repos.refetch()} /> | |
| 39 | + | ) : ( | |
| 40 | + | <FlatList | |
| 41 | + | data={repos.data} | |
| 42 | + | keyExtractor={(repo) => repo.id} | |
| 43 | + | refreshControl={<RefreshControl refreshing={repos.isRefetching} onRefresh={() => repos.refetch()} tintColor={colors.accent} />} | |
| 44 | + | ListEmptyComponent={<Empty icon={BookMarked} title={search ? "Nothing matches" : "No repositories yet"} />} | |
| 45 | + | renderItem={({ item }) => ( | |
| 46 | + | <Row | |
| 47 | + | leading={item.is_private ? <BookLock size={20} color={colors.muted} /> : <BookMarked size={20} color={colors.muted} />} | |
| 48 | + | title={`${item.namespace}/${item.name}`} | |
| 49 | + | subtitle={item.description ?? `Created ${ago(item.created_at)}`} | |
| 50 | + | trailing={item.archived_at ? <Badge label="Archived" tone="warn" /> : null} | |
| 51 | + | onPress={() => router.push({ pathname: "/code/[owner]/[repo]", params: { owner: item.namespace, repo: item.name } })} | |
| 52 | + | /> | |
| 53 | + | )} | |
| 54 | + | /> | |
| 55 | + | )} | |
| 56 | + | </Screen> | |
| 57 | + | ); | |
| 58 | + | } |
| 1 | + | import { CUSTOMIZE_SECTIONS, type CustomizeSection } from "@g1t/contracts/customize"; | |
| 2 | + | import { Redirect, Stack, useLocalSearchParams } from "expo-router"; | |
| 3 | + | import * as WebBrowser from "expo-web-browser"; | |
| 4 | + | import { ExternalLink } from "lucide-react-native"; | |
| 5 | + | import { SectionList, View } from "react-native"; | |
| 6 | + | ||
| 7 | + | import { Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| 8 | + | import { CUSTOMIZE, type CustomizeItem, customizeItems, customizePath } from "@/lib/customize"; | |
| 9 | + | import { siteUrl } from "@/lib/server"; | |
| 10 | + | import { useSignedIn } from "@/lib/session"; | |
| 11 | + | import { space } from "@/theme"; | |
| 12 | + | ||
| 13 | + | /** One part of Customize: its listings, grouped, each opening where it is set up. */ | |
| 14 | + | export default function CustomizeSectionScreen() { | |
| 15 | + | const { section } = useLocalSearchParams<{ section: string }>(); | |
| 16 | + | const { workspace, server } = useSignedIn(); | |
| 17 | + | if (!CUSTOMIZE_SECTIONS.includes(section as CustomizeSection)) return <Redirect href="/customize" />; | |
| 18 | + | const id = section as CustomizeSection; | |
| 19 | + | const slug = workspace?.slug ?? ""; | |
| 20 | + | const items = customizeItems(id, slug); | |
| 21 | + | const groups = [...new Set(items.map((item) => item.group))].map((group) => ({ title: group, data: items.filter((item) => item.group === group) })); | |
| 22 | + | const open = (path: string) => WebBrowser.openBrowserAsync(siteUrl(server, path)); | |
| 23 | + | ||
| 24 | + | return ( | |
| 25 | + | <Screen> | |
| 26 | + | <Stack.Screen options={{ title: CUSTOMIZE[id].title }} /> | |
| 27 | + | <SectionList | |
| 28 | + | sections={groups} | |
| 29 | + | keyExtractor={(item) => item.id} | |
| 30 | + | stickySectionHeadersEnabled={false} | |
| 31 | + | ListHeaderComponent={ | |
| 32 | + | <View style={{ padding: space.lg, gap: space.md }}> | |
| 33 | + | <Text tone="muted">{CUSTOMIZE[id].description}</Text> | |
| 34 | + | <Button title={`Manage ${CUSTOMIZE[id].title.toLowerCase()} on g1t`} variant="secondary" icon={ExternalLink} onPress={() => open(customizePath(id, slug))} /> | |
| 35 | + | </View> | |
| 36 | + | } | |
| 37 | + | renderSectionHeader={({ section: group }) => <SectionHeader title={group.title} />} | |
| 38 | + | renderItem={({ item }) => <Item item={item} onPress={() => open(item.sitePath)} />} | |
| 39 | + | /> | |
| 40 | + | </Screen> | |
| 41 | + | ); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | function Item({ item, onPress }: { item: CustomizeItem; onPress: () => void }) { | |
| 45 | + | const badge = item.status === "available" ? <Badge label="Available" tone="success" /> : item.status === "soon" ? <Badge label="Soon" /> : <Badge label="Built in" tone="accent" />; | |
| 46 | + | return <Row title={item.name} subtitle={item.description} trailing={badge} onPress={item.status === "soon" ? undefined : onPress} />; | |
| 47 | + | } |
| 1 | + | import { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 1 | + | import { CUSTOMIZE_SECTIONS } from "@g1t/contracts/customize"; | |
| 2 | + | import { Stack, router } from "expo-router"; | |
| 3 | + | import { Blocks, Plug, Sparkles } from "lucide-react-native"; | |
| 4 | + | import { View } from "react-native"; | |
| 5 | + | ||
| 6 | + | import { Row, Screen, Text } from "@/components/ui"; | |
| 7 | + | import { CUSTOMIZE, customizeItems } from "@/lib/customize"; | |
| 8 | + | import { useSignedIn } from "@/lib/session"; | |
| 9 | + | import { space, useColors } from "@/theme"; | |
| 10 | + | ||
| 11 | + | const ICONS = { skills: Sparkles, connectors: Plug, plugins: Blocks } as const; | |
| 12 | + | ||
| 13 | + | /** | |
| 14 | + | * Customize: what shapes how this workspace's agents work. Skills are what | |
| 15 | + | * they know how to do, Connectors what they can reach, Plugins what adds | |
| 16 | + | * to the workspace. It replaces the Marketplace. | |
| 17 | + | */ | |
| 18 | + | export default function Customize() { | |
| 19 | + | const { workspace } = useSignedIn(); | |
| 20 | + | const colors = useColors(); | |
| 21 | + | const slug = workspace?.slug ?? ""; | |
| 22 | + | return ( | |
| 23 | + | <Screen> | |
| 24 | + | <Stack.Screen options={{ title: "Customize" }} /> | |
| 25 | + | <View style={{ padding: space.lg }}> | |
| 26 | + | <Text tone="muted">Skills, connectors and plugins for {workspace?.name ?? slug}. Owners add them; anyone else asks an owner.</Text> | |
| 27 | + | </View> | |
| 28 | + | {CUSTOMIZE_SECTIONS.map((section) => { | |
| 29 | + | const Icon = ICONS[section]; | |
| 30 | + | const count = customizeItems(section, slug).length; | |
| 31 | + | return ( | |
| 32 | + | <Row | |
| 33 | + | key={section} | |
| 34 | + | leading={<Icon size={22} color={colors.accent} />} | |
| 35 | + | title={`${CUSTOMIZE[section].title} · ${count}`} | |
| 36 | + | subtitle={CUSTOMIZE[section].description} | |
| 37 | + | onPress={() => router.push({ pathname: "/customize/[section]", params: { section } })} | |
| 38 | + | /> | |
| 39 | + | ); | |
| 40 | + | })} | |
| 41 | + | </Screen> | |
| 42 | + | ); | |
| 43 | + | } |
| 1 | + | import { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 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 | + | } |
| 1 | + | import { Redirect } from "expo-router"; | |
| 2 | + | ||
| 3 | + | /** The app opens on Chat. */ | |
| 4 | + | export default function Index() { | |
| 5 | + | return <Redirect href="/chat" />; | |
| 6 | + | } |
| 1 | + | import { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 1 | + | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 2 | + | import Constants from "expo-constants"; | |
| 3 | + | import { Stack, router } from "expo-router"; | |
| 4 | + | import * as WebBrowser from "expo-web-browser"; | |
| 5 | + | import { Bell, BellRing, Blocks, Check, ExternalLink, LogOut } from "lucide-react-native"; | |
| 6 | + | import { Linking, View } from "react-native"; | |
| 7 | + | ||
| 8 | + | import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| 9 | + | import { pushState, registerForPush, sendTestPush } from "@/lib/push"; | |
| 10 | + | import { isHosted, siteUrl } from "@/lib/server"; | |
| 11 | + | import { useSignedIn } from "@/lib/session"; | |
| 12 | + | import { space, useColors } from "@/theme"; | |
| 13 | + | ||
| 14 | + | /** More: Customize, your workspaces, notifications on this phone, how you signed in, and signing out. */ | |
| 15 | + | export default function More() { | |
| 16 | + | const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn(); | |
| 17 | + | const colors = useColors(); | |
| 18 | + | const queries = useQueryClient(); | |
| 19 | + | const name = me.display_username ?? me.username; | |
| 20 | + | const oauth = client.credentials.kind === "oauth"; | |
| 21 | + | const push = useQuery({ queryKey: ["push-state"], queryFn: pushState }); | |
| 22 | + | const test = useMutation({ mutationFn: () => sendTestPush(client) }); | |
| 23 | + | ||
| 24 | + | const pushRow = { | |
| 25 | + | unsupported: { label: "Not here", detail: "Notifications need a build of the app on a phone: not Expo Go on Android, nor a simulator." }, | |
| 26 | + | denied: { label: "Off", detail: "Turned off for g1t in your phone's settings. Tap to open them." }, | |
| 27 | + | undetermined: { label: "Off", detail: "Tap to get notified of messages to you, mentions and agents waiting on you." }, | |
| 28 | + | granted: { label: "On", detail: "Messages to you, mentions, replies and agents waiting on you, when you're not at g1t elsewhere." }, | |
| 29 | + | }[push.data ?? "undetermined"]; | |
| 30 | + | ||
| 31 | + | return ( | |
| 32 | + | <Screen scroll style={{ paddingHorizontal: 0 }}> | |
| 33 | + | <Stack.Screen options={{ title: "More" }} /> | |
| 34 | + | <View style={{ flexDirection: "row", gap: space.md, alignItems: "center", paddingHorizontal: space.lg }}> | |
| 35 | + | <Avatar name={name} uri={me.avatar ? `${server.site}/avatars/${me.avatar}` : null} size={48} /> | |
| 36 | + | <View style={{ flex: 1 }}> | |
| 37 | + | <Text size="heading">{name}</Text> | |
| 38 | + | <Text size="small" tone="muted"> | |
| 39 | + | @{me.username} · {isHosted(server) ? "g1t.sh" : new URL(server.site).host} | |
| 40 | + | </Text> | |
| 41 | + | </View> | |
| 42 | + | </View> | |
| 43 | + | ||
| 44 | + | <View> | |
| 45 | + | <Row leading={<Blocks size={22} color={colors.accent} />} title="Customize" subtitle="Skills, connectors and plugins for your agents" onPress={() => router.push("/customize")} /> | |
| 46 | + | <Row | |
| 47 | + | leading={<Bell size={22} color={colors.accent} />} | |
| 48 | + | title="Notifications on this phone" | |
| 49 | + | subtitle={pushRow.detail} | |
| 50 | + | trailing={<Badge label={pushRow.label} tone={push.data === "granted" ? "success" : "neutral"} />} | |
| 51 | + | chevron={false} | |
| 52 | + | onPress={ | |
| 53 | + | push.data === "denied" | |
| 54 | + | ? () => void Linking.openSettings() | |
| 55 | + | : push.data === "undetermined" | |
| 56 | + | ? () => void registerForPush(client, true).finally(() => queries.invalidateQueries({ queryKey: ["push-state"] })) | |
| 57 | + | : undefined | |
| 58 | + | } | |
| 59 | + | /> | |
| 60 | + | {push.data === "granted" ? ( | |
| 61 | + | <Row | |
| 62 | + | leading={<BellRing size={22} color={colors.accent} />} | |
| 63 | + | title="Send a test notification" | |
| 64 | + | subtitle={ | |
| 65 | + | test.isPending | |
| 66 | + | ? "Sending…" | |
| 67 | + | : test.error | |
| 68 | + | ? test.error.message | |
| 69 | + | : test.data !== undefined | |
| 70 | + | ? test.data > 0 | |
| 71 | + | ? `Sent to ${test.data} ${test.data === 1 ? "place" : "places"}: this phone, and any browser with notifications on.` | |
| 72 | + | : "Nothing was sent: g1t has no phone or browser for you. Sign out and in again to register this phone." | |
| 73 | + | : "To check this phone gets them. It arrives in a few seconds, even with the app open; if it never does, see the setup guide's troubleshooting." | |
| 74 | + | } | |
| 75 | + | chevron={false} | |
| 76 | + | onPress={test.isPending ? undefined : () => test.mutate()} | |
| 77 | + | /> | |
| 78 | + | ) : null} | |
| 79 | + | </View> | |
| 80 | + | ||
| 81 | + | <View> | |
| 82 | + | <SectionHeader title="Workspaces" /> | |
| 83 | + | {(me.workspaces ?? []).map((one) => ( | |
| 84 | + | <Row | |
| 85 | + | key={one.slug} | |
| 86 | + | leading={<Avatar name={one.name ?? one.slug} uri={one.avatar ? `${server.site}/avatars/${one.avatar}` : null} />} | |
| 87 | + | title={one.name ?? one.slug} | |
| 88 | + | subtitle={`${one.slug} · ${one.role}`} | |
| 89 | + | trailing={one.slug === workspace?.slug ? <Check size={18} color={colors.accent} /> : null} | |
| 90 | + | chevron={false} | |
| 91 | + | onPress={() => selectWorkspace(one.slug)} | |
| 92 | + | /> | |
| 93 | + | ))} | |
| 94 | + | </View> | |
| 95 | + | ||
| 96 | + | <View> | |
| 97 | + | <SectionHeader title="Signed in" /> | |
| 98 | + | <Row | |
| 99 | + | title={oauth ? "With g1t" : "With an access token"} | |
| 100 | + | subtitle={oauth ? "End or narrow the app's access in Settings → Connected applications." : "The token you pasted. Delete it in Settings → Access tokens to end it."} | |
| 101 | + | trailing={<ExternalLink size={16} color={colors.faint} />} | |
| 102 | + | chevron={false} | |
| 103 | + | onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, oauth ? "/settings/applications" : "/settings/tokens"))} | |
| 104 | + | /> | |
| 105 | + | </View> | |
| 106 | + | ||
| 107 | + | <View style={{ paddingHorizontal: space.lg, gap: space.md }}> | |
| 108 | + | <Button title="Sign out" variant="danger" icon={LogOut} onPress={signOut} /> | |
| 109 | + | <Text size="small" tone="faint" style={{ textAlign: "center" }}> | |
| 110 | + | g1t {Constants.expoConfig?.version ?? ""} | |
| 111 | + | </Text> | |
| 112 | + | </View> | |
| 113 | + | </Screen> | |
| 114 | + | ); | |
| 115 | + | } |
| 1 | + | import { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 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 | + | } |
| 1 | + | import { routeFor, sitePathOf } from "@/lib/links"; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * Where a link that opens the app goes. `g1t://` links name a site path, | |
| 5 | + | * as on the desktop; the app shows the ones it has screens for and opens | |
| 6 | + | * the rest on the site (app/open.tsx). Android App Links and iOS | |
| 7 | + | * Universal Links for https://g1t.sh come through here too once the site | |
| 8 | + | * publishes assetlinks.json and apple-app-site-association. | |
| 9 | + | */ | |
| 10 | + | export function redirectSystemPath({ path }: { path: string; initial: boolean }): string { | |
| 11 | + | try { | |
| 12 | + | const sitePath = sitePathOf(path) ?? sitePathOfWeb(path) ?? path; | |
| 13 | + | const route = routeFor(sitePath); | |
| 14 | + | return route ?? `/open?path=${encodeURIComponent(sitePath)}`; | |
| 15 | + | } catch { | |
| 16 | + | return "/"; | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | function sitePathOfWeb(link: string): string | null { | |
| 21 | + | if (!/^https?:\/\//i.test(link)) return null; | |
| 22 | + | const url = new URL(link); | |
| 23 | + | return url.pathname + url.search; | |
| 24 | + | } |
| 1 | + | import { Link, Stack } from "expo-router"; | |
| 2 | + | import { CircleHelp } from "lucide-react-native"; | |
| 3 | + | ||
| 4 | + | import { Empty, Screen, Text } from "@/components/ui"; | |
| 5 | + | ||
| 6 | + | export default function NotFound() { | |
| 7 | + | return ( | |
| 8 | + | <Screen safe> | |
| 9 | + | <Stack.Screen options={{ headerShown: false }} /> | |
| 10 | + | <Empty | |
| 11 | + | icon={CircleHelp} | |
| 12 | + | title="Nothing here" | |
| 13 | + | body="The app has no screen for this link." | |
| 14 | + | action={ | |
| 15 | + | <Link href="/" replace> | |
| 16 | + | <Text tone="accent">Go to Chat</Text> | |
| 17 | + | </Link> | |
| 18 | + | } | |
| 19 | + | /> | |
| 20 | + | </Screen> | |
| 21 | + | ); | |
| 22 | + | } |
| 1 | + | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | |
| 2 | + | import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from "expo-router"; | |
| 3 | + | import * as SplashScreen from "expo-splash-screen"; | |
| 4 | + | import { StatusBar } from "expo-status-bar"; | |
| 5 | + | import { useEffect, useState } from "react"; | |
| 6 | + | import { useColorScheme } from "react-native"; | |
| 7 | + | ||
| 8 | + | import { ApiError } from "@/lib/client"; | |
| 9 | + | import { SessionProvider, useSession } from "@/lib/session"; | |
| 10 | + | import { useColors } from "@/theme"; | |
| 11 | + | ||
| 12 | + | void SplashScreen.preventAutoHideAsync(); | |
| 13 | + | ||
| 14 | + | export default function RootLayout() { | |
| 15 | + | const [queries] = useState( | |
| 16 | + | () => | |
| 17 | + | new QueryClient({ | |
| 18 | + | defaultOptions: { | |
| 19 | + | queries: { | |
| 20 | + | staleTime: 30_000, | |
| 21 | + | // Signed out, not found and refused are answers, not hiccups. | |
| 22 | + | retry: (count, error) => !(error instanceof ApiError && error.code !== "offline") && count < 2, | |
| 23 | + | }, | |
| 24 | + | }, | |
| 25 | + | }), | |
| 26 | + | ); | |
| 27 | + | return ( | |
| 28 | + | <QueryClientProvider client={queries}> | |
| 29 | + | <SessionProvider> | |
| 30 | + | <Navigation /> | |
| 31 | + | </SessionProvider> | |
| 32 | + | </QueryClientProvider> | |
| 33 | + | ); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | function Navigation() { | |
| 37 | + | const { state } = useSession(); | |
| 38 | + | const scheme = useColorScheme(); | |
| 39 | + | const colors = useColors(); | |
| 40 | + | const base = scheme === "light" ? DefaultTheme : DarkTheme; | |
| 41 | + | const theme = { | |
| 42 | + | ...base, | |
| 43 | + | colors: { ...base.colors, primary: colors.accent, background: colors.bg, card: colors.surface, text: colors.fg, border: colors.line }, | |
| 44 | + | }; | |
| 45 | + | ||
| 46 | + | useEffect(() => { | |
| 47 | + | if (state.status !== "loading") void SplashScreen.hideAsync(); | |
| 48 | + | }, [state.status]); | |
| 49 | + | ||
| 50 | + | if (state.status === "loading") return null; | |
| 51 | + | const signedIn = state.status === "signed-in"; | |
| 52 | + | ||
| 53 | + | return ( | |
| 54 | + | <ThemeProvider value={theme}> | |
| 55 | + | <StatusBar style={scheme === "light" ? "dark" : "light"} /> | |
| 56 | + | <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}> | |
| 57 | + | <Stack.Protected guard={signedIn}> | |
| 58 | + | <Stack.Screen name="(app)" /> | |
| 59 | + | </Stack.Protected> | |
| 60 | + | <Stack.Protected guard={!signedIn}> | |
| 61 | + | <Stack.Screen name="sign-in" /> | |
| 62 | + | </Stack.Protected> | |
| 63 | + | <Stack.Screen name="oauth/callback" /> | |
| 64 | + | <Stack.Screen name="open" /> | |
| 65 | + | </Stack> | |
| 66 | + | </ThemeProvider> | |
| 67 | + | ); | |
| 68 | + | } |
| 1 | + | import { Redirect } from "expo-router"; | |
| 2 | + | ||
| 3 | + | import { Loading, Screen } from "@/components/ui"; | |
| 4 | + | import { useSession } from "@/lib/session"; | |
| 5 | + | ||
| 6 | + | /** | |
| 7 | + | * Where the site sends the person back after they approve the app | |
| 8 | + | * (`g1t://oauth/callback`). The sign-in screen's browser session reads | |
| 9 | + | * the code from the address itself (lib/oauth.ts); this screen is only | |
| 10 | + | * what shows if the link opens the app on its own, and it moves on. | |
| 11 | + | */ | |
| 12 | + | export default function OAuthCallback() { | |
| 13 | + | const { state } = useSession(); | |
| 14 | + | if (state.status === "loading") { | |
| 15 | + | return ( | |
| 16 | + | <Screen> | |
| 17 | + | <Loading /> | |
| 18 | + | </Screen> | |
| 19 | + | ); | |
| 20 | + | } | |
| 21 | + | return <Redirect href={state.status === "signed-in" ? "/chat" : "/sign-in"} />; | |
| 22 | + | } |
| 1 | + | import { router, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import * as WebBrowser from "expo-web-browser"; | |
| 3 | + | import { useEffect } from "react"; | |
| 4 | + | ||
| 5 | + | import { Loading, Screen } from "@/components/ui"; | |
| 6 | + | import { useSession } from "@/lib/session"; | |
| 7 | + | import { siteUrl } from "@/lib/server"; | |
| 8 | + | ||
| 9 | + | /** | |
| 10 | + | * A link to a page the app has no screen for: it opens on the site, in | |
| 11 | + | * the system's browser, and the app goes back to where it was. | |
| 12 | + | */ | |
| 13 | + | export default function Open() { | |
| 14 | + | const { path } = useLocalSearchParams<{ path?: string }>(); | |
| 15 | + | const { state } = useSession(); | |
| 16 | + | ||
| 17 | + | useEffect(() => { | |
| 18 | + | const server = state.server; | |
| 19 | + | if (!server) return; | |
| 20 | + | const safe = path && path.startsWith("/") ? path : "/"; | |
| 21 | + | void WebBrowser.openBrowserAsync(siteUrl(server, safe)).finally(() => { | |
| 22 | + | if (router.canGoBack()) router.back(); | |
| 23 | + | else router.replace("/"); | |
| 24 | + | }); | |
| 25 | + | }, [path, state.server]); | |
| 26 | + | ||
| 27 | + | return ( | |
| 28 | + | <Screen> | |
| 29 | + | <Loading /> | |
| 30 | + | </Screen> | |
| 31 | + | ); | |
| 32 | + | } |
| 1 | + | import { KeyRound, LogIn, Server as ServerIcon } from "lucide-react-native"; | |
| 2 | + | import { useState } from "react"; | |
| 3 | + | import { KeyboardAvoidingView, Platform, View } from "react-native"; | |
| 4 | + | ||
| 5 | + | import { Button, Field, Screen, Text } from "@/components/ui"; | |
| 6 | + | import { G1tMark } from "@/components/mark"; | |
| 7 | + | import { SignInCancelled } from "@/lib/oauth"; | |
| 8 | + | import { HOSTED, isHosted, parseServer } from "@/lib/server"; | |
| 9 | + | import { useSession } from "@/lib/session"; | |
| 10 | + | import { space } from "@/theme"; | |
| 11 | + | ||
| 12 | + | /** | |
| 13 | + | * Signing in. The usual way is **Sign in with g1t**: the site opens in the | |
| 14 | + | * system's browser, the person approves the app, and it comes back signed | |
| 15 | + | * in (lib/oauth.ts). **Use an access token** takes a personal token | |
| 16 | + | * instead. **Server** points the app at a self-hosted g1t. | |
| 17 | + | */ | |
| 18 | + | export default function SignIn() { | |
| 19 | + | const { state, signIn, signInWithToken, changeServer } = useSession(); | |
| 20 | + | const server = state.server ?? HOSTED; | |
| 21 | + | const [mode, setMode] = useState<"oauth" | "token" | "server">("oauth"); | |
| 22 | + | const [busy, setBusy] = useState(false); | |
| 23 | + | const [error, setError] = useState<string | null>(null); | |
| 24 | + | const [token, setToken] = useState(""); | |
| 25 | + | const [site, setSite] = useState(server.site); | |
| 26 | + | const [api, setApi] = useState(isHosted(server) ? "" : server.api); | |
| 27 | + | ||
| 28 | + | const run = async (work: () => Promise<void>) => { | |
| 29 | + | setBusy(true); | |
| 30 | + | setError(null); | |
| 31 | + | try { | |
| 32 | + | await work(); | |
| 33 | + | } catch (failure) { | |
| 34 | + | if (!(failure instanceof SignInCancelled)) setError(failure instanceof Error ? failure.message : "Sign-in failed."); | |
| 35 | + | } finally { | |
| 36 | + | setBusy(false); | |
| 37 | + | } | |
| 38 | + | }; | |
| 39 | + | ||
| 40 | + | return ( | |
| 41 | + | <Screen safe> | |
| 42 | + | <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : undefined} style={{ flex: 1 }}> | |
| 43 | + | <View style={{ flex: 1, padding: space.xl, justifyContent: "center", gap: space.xl }}> | |
| 44 | + | <View style={{ gap: space.md }}> | |
| 45 | + | <G1tMark size={44} /> | |
| 46 | + | <Text size="title">Sign in to g1t</Text> | |
| 47 | + | <Text tone="muted">Chat with your team and its agents, and review and merge what they make.</Text> | |
| 48 | + | </View> | |
| 49 | + | ||
| 50 | + | {mode === "oauth" ? ( | |
| 51 | + | <View style={{ gap: space.md }}> | |
| 52 | + | <Button title="Sign in with g1t" icon={LogIn} busy={busy} onPress={() => run(signIn)} /> | |
| 53 | + | <Text size="small" tone="faint"> | |
| 54 | + | {new URL(server.site).host} opens in your browser to approve the app. Your password never reaches it. | |
| 55 | + | </Text> | |
| 56 | + | </View> | |
| 57 | + | ) : null} | |
| 58 | + | ||
| 59 | + | {mode === "token" ? ( | |
| 60 | + | <View style={{ gap: space.md }}> | |
| 61 | + | <Field | |
| 62 | + | label="Access token" | |
| 63 | + | placeholder="g1t_…" | |
| 64 | + | value={token} | |
| 65 | + | onChangeText={setToken} | |
| 66 | + | secureTextEntry | |
| 67 | + | hint="Make one in Settings → Access tokens. For Chat, turn on “Use the website as you”." | |
| 68 | + | /> | |
| 69 | + | <Button title="Sign in" busy={busy} disabled={!token.trim()} onPress={() => run(() => signInWithToken(token))} /> | |
| 70 | + | </View> | |
| 71 | + | ) : null} | |
| 72 | + | ||
| 73 | + | {mode === "server" ? ( | |
| 74 | + | <View style={{ gap: space.md }}> | |
| 75 | + | <Field label="Site" placeholder="g1t.example.com" value={site} onChangeText={setSite} keyboardType="url" /> | |
| 76 | + | <Field | |
| 77 | + | label="API (optional)" | |
| 78 | + | placeholder={`api.${site ? site.replace(/^https?:\/\//, "") : "g1t.example.com"}`} | |
| 79 | + | value={api} | |
| 80 | + | onChangeText={setApi} | |
| 81 | + | keyboardType="url" | |
| 82 | + | hint="Left empty, the API is api. in front of the site's address." | |
| 83 | + | /> | |
| 84 | + | <Button | |
| 85 | + | title="Use this server" | |
| 86 | + | busy={busy} | |
| 87 | + | onPress={() => | |
| 88 | + | run(async () => { | |
| 89 | + | const next = parseServer(site, api); | |
| 90 | + | if (!next) throw new Error("That is not a web address. It looks like https://g1t.example.com."); | |
| 91 | + | await changeServer(next); | |
| 92 | + | setMode("oauth"); | |
| 93 | + | }) | |
| 94 | + | } | |
| 95 | + | /> | |
| 96 | + | <Button | |
| 97 | + | title="Use g1t.sh" | |
| 98 | + | variant="secondary" | |
| 99 | + | onPress={() => { | |
| 100 | + | setSite(HOSTED.site); | |
| 101 | + | setApi(""); | |
| 102 | + | void run(async () => { | |
| 103 | + | await changeServer(HOSTED); | |
| 104 | + | setMode("oauth"); | |
| 105 | + | }); | |
| 106 | + | }} | |
| 107 | + | /> | |
| 108 | + | </View> | |
| 109 | + | ) : null} | |
| 110 | + | ||
| 111 | + | {error ? <Text tone="danger">{error}</Text> : null} | |
| 112 | + | ||
| 113 | + | <View style={{ gap: space.sm }}> | |
| 114 | + | {mode !== "oauth" ? <Button title="Sign in with g1t instead" variant="secondary" icon={LogIn} onPress={() => setMode("oauth")} /> : null} | |
| 115 | + | {mode !== "token" ? <Button title="Use an access token" variant="secondary" icon={KeyRound} onPress={() => setMode("token")} /> : null} | |
| 116 | + | {mode !== "server" ? ( | |
| 117 | + | <Button title={isHosted(server) ? "Server: g1t.sh" : `Server: ${new URL(server.site).host}`} variant="secondary" icon={ServerIcon} onPress={() => setMode("server")} /> | |
| 118 | + | ) : null} | |
| 119 | + | </View> | |
| 120 | + | </View> | |
| 121 | + | </KeyboardAvoidingView> | |
| 122 | + | </Screen> | |
| 123 | + | ); | |
| 124 | + | } |
| 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 | + | }); |
| 1 | + | import * as Haptics from "expo-haptics"; | |
| 2 | + | import { ArrowUp } from "lucide-react-native"; | |
| 3 | + | import { useEffect, useState } from "react"; | |
| 4 | + | import { Keyboard, Pressable, StyleSheet, TextInput, View } from "react-native"; | |
| 5 | + | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 6 | + | ||
| 7 | + | import { radius, space, useColors } from "@/theme"; | |
| 8 | + | ||
| 9 | + | /** What was typed and not sent, per conversation, while the app runs. */ | |
| 10 | + | const drafts = new Map<string, string>(); | |
| 11 | + | ||
| 12 | + | /** | |
| 13 | + | * The box at the bottom of a conversation. Enter makes a new line, as on | |
| 14 | + | * a phone; the arrow sends. Typing tells the others (at most every 3 s). | |
| 15 | + | */ | |
| 16 | + | export function Composer({ | |
| 17 | + | draftKey, | |
| 18 | + | placeholder, | |
| 19 | + | onSend, | |
| 20 | + | onTyping, | |
| 21 | + | onFocus, | |
| 22 | + | }: { | |
| 23 | + | draftKey: string; | |
| 24 | + | placeholder: string; | |
| 25 | + | onSend: (body: string) => void; | |
| 26 | + | onTyping: () => void; | |
| 27 | + | /** The box was tapped into: the conversation goes to its newest message. */ | |
| 28 | + | onFocus?: () => void; | |
| 29 | + | }) { | |
| 30 | + | const colors = useColors(); | |
| 31 | + | const insets = useSafeAreaInsets(); | |
| 32 | + | const [text, setText] = useState(() => drafts.get(draftKey) ?? ""); | |
| 33 | + | const ready = text.trim().length > 0; | |
| 34 | + | // With the keyboard up the box sits on it; the home indicator's room is only for when it is down. | |
| 35 | + | const [keyboard, setKeyboard] = useState(false); | |
| 36 | + | useEffect(() => { | |
| 37 | + | const shown = Keyboard.addListener("keyboardDidShow", () => setKeyboard(true)); | |
| 38 | + | const hidden = Keyboard.addListener("keyboardDidHide", () => setKeyboard(false)); | |
| 39 | + | return () => { | |
| 40 | + | shown.remove(); | |
| 41 | + | hidden.remove(); | |
| 42 | + | }; | |
| 43 | + | }, []); | |
| 44 | + | ||
| 45 | + | const send = () => { | |
| 46 | + | const body = text.trim(); | |
| 47 | + | if (!body) return; | |
| 48 | + | void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined); | |
| 49 | + | onSend(body); | |
| 50 | + | setText(""); | |
| 51 | + | drafts.delete(draftKey); | |
| 52 | + | }; | |
| 53 | + | ||
| 54 | + | return ( | |
| 55 | + | <View style={[styles.bar, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: keyboard ? space.sm : Math.max(insets.bottom, space.sm) }]}> | |
| 56 | + | <TextInput | |
| 57 | + | value={text} | |
| 58 | + | onChangeText={(next) => { | |
| 59 | + | setText(next); | |
| 60 | + | drafts.set(draftKey, next); | |
| 61 | + | if (next.length > 0) onTyping(); | |
| 62 | + | }} | |
| 63 | + | placeholder={placeholder} | |
| 64 | + | placeholderTextColor={colors.faint} | |
| 65 | + | onFocus={onFocus} | |
| 66 | + | multiline | |
| 67 | + | style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]} | |
| 68 | + | /> | |
| 69 | + | <Pressable | |
| 70 | + | accessibilityRole="button" | |
| 71 | + | accessibilityLabel="Send" | |
| 72 | + | onPress={send} | |
| 73 | + | disabled={!ready} | |
| 74 | + | style={[styles.send, { backgroundColor: ready ? colors.accent : colors.raised }]} | |
| 75 | + | > | |
| 76 | + | <ArrowUp size={20} color={ready ? colors.onAccent : colors.faint} strokeWidth={2.5} /> | |
| 77 | + | </Pressable> | |
| 78 | + | </View> | |
| 79 | + | ); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | const styles = StyleSheet.create({ | |
| 83 | + | bar: { flexDirection: "row", alignItems: "flex-end", gap: space.sm, paddingHorizontal: space.md, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth }, | |
| 84 | + | input: { flex: 1, minHeight: 40, maxHeight: 160, borderRadius: radius.lg, borderWidth: 1, paddingHorizontal: space.md, paddingTop: 10, paddingBottom: 10, fontSize: 15 }, | |
| 85 | + | send: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center" }, | |
| 86 | + | }); |
| 1 | + | import type { CardAction, ChatMessage } from "@g1t/contracts"; | |
| 2 | + | import { useQueryClient } from "@tanstack/react-query"; | |
| 3 | + | import * as Clipboard from "expo-clipboard"; | |
| 4 | + | import { router, useFocusEffect } from "expo-router"; | |
| 5 | + | import { useHeaderHeight } from "expo-router/react-navigation"; | |
| 6 | + | import { ArrowDown, Copy, MessageSquareReply, Pencil, Trash2 } from "lucide-react-native"; | |
| 7 | + | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 8 | + | import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Pressable, StyleSheet, View } from "react-native"; | |
| 9 | + | ||
| 10 | + | import { Composer } from "@/components/chat/composer"; | |
| 11 | + | import { Message, runsOn } from "@/components/chat/message"; | |
| 12 | + | import { PromptSheet, Sheet, SheetAction } from "@/components/chat/sheet"; | |
| 13 | + | import { ErrorView, Loading, Text } from "@/components/ui"; | |
| 14 | + | import { type Shown, markRead, useChannelLive, useChatActions, useMessages, useSend } from "@/lib/chat"; | |
| 15 | + | import { useSignedIn } from "@/lib/session"; | |
| 16 | + | import { viewing } from "@/lib/viewing"; | |
| 17 | + | import { radius, space, useColors } from "@/theme"; | |
| 18 | + | ||
| 19 | + | const QUICK = ["👍", "❤️", "😂", "🎉", "👀", "✅"]; | |
| 20 | + | ||
| 21 | + | type Row = { kind: "message"; message: Shown; head: boolean } | { kind: "day"; key: string; label: string }; | |
| 22 | + | ||
| 23 | + | /** | |
| 24 | + | * A conversation, or one thread of it: messages newest at the bottom, live | |
| 25 | + | * while open, read as they arrive, and a box to write in. Long-press a | |
| 26 | + | * message to react, reply in a thread, copy, or change or delete your own. | |
| 27 | + | */ | |
| 28 | + | export function Conversation({ channelId, thread, title }: { channelId: string; thread: string | null; title: string }) { | |
| 29 | + | const colors = useColors(); | |
| 30 | + | const queries = useQueryClient(); | |
| 31 | + | const { client, workspace, me } = useSignedIn(); | |
| 32 | + | const slug = workspace?.slug ?? ""; | |
| 33 | + | const messages = useMessages(channelId, thread); | |
| 34 | + | const live = useChannelLive(channelId); | |
| 35 | + | const send = useSend(channelId, thread); | |
| 36 | + | const actions = useChatActions(channelId); | |
| 37 | + | const [picked, setPicked] = useState<Shown | null>(null); | |
| 38 | + | const [editing, setEditing] = useState<Shown | null>(null); | |
| 39 | + | const [scrolledUp, setScrolledUp] = useState(false); | |
| 40 | + | const list = useRef<FlatList<Row>>(null); | |
| 41 | + | const focused = useRef(false); | |
| 42 | + | const headerHeight = useHeaderHeight(); | |
| 43 | + | ||
| 44 | + | // While on screen, this conversation's banners and pushes are not shown. | |
| 45 | + | useFocusEffect( | |
| 46 | + | useCallback(() => { | |
| 47 | + | focused.current = true; | |
| 48 | + | viewing.channelId = channelId; | |
| 49 | + | viewing.threadRoot = thread; | |
| 50 | + | return () => { | |
| 51 | + | focused.current = false; | |
| 52 | + | if (viewing.channelId === channelId) { | |
| 53 | + | viewing.channelId = null; | |
| 54 | + | viewing.threadRoot = null; | |
| 55 | + | } | |
| 56 | + | }; | |
| 57 | + | }, [channelId, thread]), | |
| 58 | + | ); | |
| 59 | + | ||
| 60 | + | const all = useMemo(() => { | |
| 61 | + | const pages = messages.data?.pages ?? []; | |
| 62 | + | // Oldest first: the last page read is the oldest. | |
| 63 | + | const ordered = [...pages].reverse().flatMap((page) => page.messages as Shown[]); | |
| 64 | + | // A thread's first page carries the message it is under. | |
| 65 | + | const root = thread ? (pages[0]?.root ?? null) : null; | |
| 66 | + | const shown = ordered.filter((message) => !message.deleted_at && message.id !== root?.id); | |
| 67 | + | return root ? [root as Shown, ...shown] : shown; | |
| 68 | + | }, [messages.data, thread]); | |
| 69 | + | ||
| 70 | + | // Rows newest first, for a list drawn from the bottom up, with the day between days. | |
| 71 | + | const rows = useMemo(() => { | |
| 72 | + | const out: Row[] = []; | |
| 73 | + | let day = ""; | |
| 74 | + | all.forEach((message, index) => { | |
| 75 | + | const date = new Date(message.created_at); | |
| 76 | + | const label = dayLabel(date); | |
| 77 | + | if (label !== day) { | |
| 78 | + | out.push({ kind: "day", key: `day-${label}-${index}`, label }); | |
| 79 | + | day = label; | |
| 80 | + | } | |
| 81 | + | out.push({ kind: "message", message, head: label !== dayLabel(new Date(all[index - 1]?.created_at ?? 0)) || !runsOn(all[index - 1], message) }); | |
| 82 | + | }); | |
| 83 | + | return out.reverse(); | |
| 84 | + | }, [all]); | |
| 85 | + | ||
| 86 | + | // Read up to the newest while it is on screen. | |
| 87 | + | const newest = all.filter((message) => !message.local).at(-1)?.id; | |
| 88 | + | useEffect(() => { | |
| 89 | + | if (slug && newest && focused.current && !thread) void markRead(client, queries, slug, channelId, newest); | |
| 90 | + | }, [client, queries, slug, channelId, newest, thread]); | |
| 91 | + | ||
| 92 | + | const onCardAction = useCallback( | |
| 93 | + | async (message: ChatMessage, action: CardAction, input?: string | null) => { | |
| 94 | + | const answer = await actions.cardAction.mutateAsync({ message, action: action.id, input }); | |
| 95 | + | if (!answer.ok) throw new Error(answer.message ?? "That didn't work."); | |
| 96 | + | return answer.message; | |
| 97 | + | }, | |
| 98 | + | [actions.cardAction], | |
| 99 | + | ); | |
| 100 | + | ||
| 101 | + | const onReact = useCallback((message: ChatMessage, emoji: string, add: boolean) => actions.react.mutate({ message, emoji, add }), [actions.react]); | |
| 102 | + | const onRetry = useCallback((message: Shown) => send(message.body, message), [send]); | |
| 103 | + | ||
| 104 | + | const mine = picked?.author.kind === "user" && picked.author.id === me.id; | |
| 105 | + | const typingLine = typingText(live.typing.map((member) => member.display_name)); | |
| 106 | + | ||
| 107 | + | return ( | |
| 108 | + | // The keyboard pushes the composer up and the list shrinks from the top, on | |
| 109 | + | // Android too: apps draw edge to edge there, so the window no longer resizes | |
| 110 | + | // for it. The list is drawn from the bottom, so the newest stays in view. | |
| 111 | + | <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}> | |
| 112 | + | <View style={{ flex: 1 }}> | |
| 113 | + | {messages.isPending ? ( | |
| 114 | + | <Loading /> | |
| 115 | + | ) : messages.error ? ( | |
| 116 | + | <ErrorView error={messages.error} retry={() => messages.refetch()} /> | |
| 117 | + | ) : ( | |
| 118 | + | <FlatList | |
| 119 | + | ref={list} | |
| 120 | + | inverted | |
| 121 | + | data={rows} | |
| 122 | + | keyExtractor={(row) => (row.kind === "day" ? row.key : row.message.id)} | |
| 123 | + | renderItem={({ item }) => | |
| 124 | + | item.kind === "day" ? ( | |
| 125 | + | <View style={styles.day}> | |
| 126 | + | <View style={[styles.dayLine, { backgroundColor: colors.line }]} /> | |
| 127 | + | <Text size="small" tone="faint" style={{ fontWeight: "600" }}> | |
| 128 | + | {item.label} | |
| 129 | + | </Text> | |
| 130 | + | <View style={[styles.dayLine, { backgroundColor: colors.line }]} /> | |
| 131 | + | </View> | |
| 132 | + | ) : ( | |
| 133 | + | <Message | |
| 134 | + | message={item.message} | |
| 135 | + | head={item.head} | |
| 136 | + | inThread={!!thread} | |
| 137 | + | onLongPress={setPicked} | |
| 138 | + | onReact={onReact} | |
| 139 | + | onRetry={onRetry} | |
| 140 | + | onCardAction={onCardAction} | |
| 141 | + | /> | |
| 142 | + | ) | |
| 143 | + | } | |
| 144 | + | onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()} | |
| 145 | + | onEndReachedThreshold={0.6} | |
| 146 | + | onScroll={(event) => setScrolledUp(event.nativeEvent.contentOffset.y > 600)} | |
| 147 | + | scrollEventThrottle={100} | |
| 148 | + | ListHeaderComponent={<View style={{ height: space.sm }} />} | |
| 149 | + | ListFooterComponent={ | |
| 150 | + | messages.isFetchingNextPage ? ( | |
| 151 | + | <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} /> | |
| 152 | + | ) : !messages.hasNextPage ? ( | |
| 153 | + | <Text size="small" tone="faint" style={{ textAlign: "center", padding: space.xl }}> | |
| 154 | + | {thread ? "The start of this thread." : `The start of ${title}.`} | |
| 155 | + | </Text> | |
| 156 | + | ) : null | |
| 157 | + | } | |
| 158 | + | keyboardDismissMode="interactive" | |
| 159 | + | keyboardShouldPersistTaps="handled" | |
| 160 | + | /> | |
| 161 | + | )} | |
| 162 | + | {scrolledUp ? ( | |
| 163 | + | <Pressable | |
| 164 | + | accessibilityLabel="Go to newest" | |
| 165 | + | onPress={() => list.current?.scrollToOffset({ offset: 0, animated: true })} | |
| 166 | + | style={[styles.jump, { backgroundColor: colors.surface, borderColor: colors.lineStrong }]} | |
| 167 | + | > | |
| 168 | + | <ArrowDown size={18} color={colors.fg} /> | |
| 169 | + | </Pressable> | |
| 170 | + | ) : null} | |
| 171 | + | </View> | |
| 172 | + | <View style={styles.typing}> | |
| 173 | + | <Text size="small" tone="faint" numberOfLines={1}> | |
| 174 | + | {typingLine} | |
| 175 | + | </Text> | |
| 176 | + | {live.status !== "live" ? ( | |
| 177 | + | <Text size="small" tone="faint"> | |
| 178 | + | {live.status === "connecting" ? "Connecting…" : "Offline, trying again"} | |
| 179 | + | </Text> | |
| 180 | + | ) : null} | |
| 181 | + | </View> | |
| 182 | + | <Composer | |
| 183 | + | draftKey={`${channelId}:${thread ?? ""}`} | |
| 184 | + | placeholder={thread ? "Reply in thread" : `Message ${title}`} | |
| 185 | + | onSend={(body) => { | |
| 186 | + | send(body); | |
| 187 | + | list.current?.scrollToOffset({ offset: 0, animated: true }); | |
| 188 | + | }} | |
| 189 | + | onTyping={live.sayTyping} | |
| 190 | + | onFocus={() => list.current?.scrollToOffset({ offset: 0, animated: true })} | |
| 191 | + | /> | |
| 192 | + | ||
| 193 | + | <Sheet visible={!!picked} onClose={() => setPicked(null)}> | |
| 194 | + | <View style={styles.quick}> | |
| 195 | + | {QUICK.map((emoji) => { | |
| 196 | + | const had = picked?.reactions?.find((one) => one.emoji === emoji)?.me ?? false; | |
| 197 | + | return ( | |
| 198 | + | <Pressable | |
| 199 | + | key={emoji} | |
| 200 | + | onPress={() => { | |
| 201 | + | if (picked) onReact(picked, emoji, !had); | |
| 202 | + | setPicked(null); | |
| 203 | + | }} | |
| 204 | + | style={[styles.quickOne, { backgroundColor: had ? colors.accentLow : colors.raised }]} | |
| 205 | + | > | |
| 206 | + | <Text style={{ fontSize: 24, lineHeight: 30 }}>{emoji}</Text> | |
| 207 | + | </Pressable> | |
| 208 | + | ); | |
| 209 | + | })} | |
| 210 | + | </View> | |
| 211 | + | {!thread ? ( | |
| 212 | + | <SheetAction | |
| 213 | + | icon={MessageSquareReply} | |
| 214 | + | label="Reply in thread" | |
| 215 | + | onPress={() => { | |
| 216 | + | const message = picked; | |
| 217 | + | setPicked(null); | |
| 218 | + | if (message) router.push({ pathname: "/chat/thread", params: { channel: channelId, root: message.id } }); | |
| 219 | + | }} | |
| 220 | + | /> | |
| 221 | + | ) : null} | |
| 222 | + | <SheetAction | |
| 223 | + | icon={Copy} | |
| 224 | + | label="Copy text" | |
| 225 | + | onPress={() => { | |
| 226 | + | if (picked?.body) void Clipboard.setStringAsync(picked.body); | |
| 227 | + | setPicked(null); | |
| 228 | + | }} | |
| 229 | + | /> | |
| 230 | + | {mine && picked?.kind === "text" ? ( | |
| 231 | + | <SheetAction | |
| 232 | + | icon={Pencil} | |
| 233 | + | label="Edit" | |
| 234 | + | onPress={() => { | |
| 235 | + | setEditing(picked); | |
| 236 | + | setPicked(null); | |
| 237 | + | }} | |
| 238 | + | /> | |
| 239 | + | ) : null} | |
| 240 | + | {mine ? ( | |
| 241 | + | <SheetAction | |
| 242 | + | icon={Trash2} | |
| 243 | + | label="Delete" | |
| 244 | + | danger | |
| 245 | + | onPress={() => { | |
| 246 | + | const message = picked; | |
| 247 | + | setPicked(null); | |
| 248 | + | if (!message) return; | |
| 249 | + | Alert.alert("Delete this message?", "It is gone for everyone in the conversation.", [ | |
| 250 | + | { text: "Cancel", style: "cancel" }, | |
| 251 | + | { text: "Delete", style: "destructive", onPress: () => actions.remove.mutate(message) }, | |
| 252 | + | ]); | |
| 253 | + | }} | |
| 254 | + | /> | |
| 255 | + | ) : null} | |
| 256 | + | </Sheet> | |
| 257 | + | ||
| 258 | + | <PromptSheet | |
| 259 | + | visible={!!editing} | |
| 260 | + | title="Edit message" | |
| 261 | + | initial={editing?.body ?? ""} | |
| 262 | + | multiline | |
| 263 | + | confirm="Save" | |
| 264 | + | onCancel={() => setEditing(null)} | |
| 265 | + | onDone={(body) => { | |
| 266 | + | if (editing && body !== editing.body) actions.edit.mutate({ message: editing, body }); | |
| 267 | + | setEditing(null); | |
| 268 | + | }} | |
| 269 | + | /> | |
| 270 | + | </KeyboardAvoidingView> | |
| 271 | + | ); | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | function dayLabel(date: Date): string { | |
| 275 | + | const today = new Date(); | |
| 276 | + | const yesterday = new Date(today.getTime() - 86_400_000); | |
| 277 | + | if (date.toDateString() === today.toDateString()) return "Today"; | |
| 278 | + | if (date.toDateString() === yesterday.toDateString()) return "Yesterday"; | |
| 279 | + | return date.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", ...(date.getFullYear() !== today.getFullYear() ? { year: "numeric" } : {}) }); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | function typingText(names: string[]): string { | |
| 283 | + | if (names.length === 0) return ""; | |
| 284 | + | if (names.length === 1) return `${names[0]} is typing…`; | |
| 285 | + | if (names.length === 2) return `${names[0]} and ${names[1]} are typing…`; | |
| 286 | + | return "Several people are typing…"; | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | const styles = StyleSheet.create({ | |
| 290 | + | day: { flexDirection: "row", alignItems: "center", gap: space.sm, paddingHorizontal: space.lg, paddingVertical: space.md }, | |
| 291 | + | dayLine: { flex: 1, height: StyleSheet.hairlineWidth }, | |
| 292 | + | jump: { position: "absolute", right: space.lg, bottom: space.md, width: 40, height: 40, borderRadius: 20, borderWidth: 1, alignItems: "center", justifyContent: "center" }, | |
| 293 | + | typing: { flexDirection: "row", justifyContent: "space-between", paddingHorizontal: space.lg, minHeight: 18 }, | |
| 294 | + | quick: { flexDirection: "row", justifyContent: "space-around", paddingHorizontal: space.md, paddingBottom: space.sm }, | |
| 295 | + | quickOne: { width: 48, height: 48, borderRadius: radius.full, alignItems: "center", justifyContent: "center" }, | |
| 296 | + | }); |
| 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 | + | }); |
| 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 | + | }); |
| 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 | + | }); |
| 1 | + | import { CircleCheck, CircleDot, CircleX, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Loader } from "lucide-react-native"; | |
| 2 | + | import { ScrollView, StyleSheet, View } from "react-native"; | |
| 3 | + | ||
| 4 | + | import { Badge, Text } from "@/components/ui"; | |
| 5 | + | import type { ApiChanges, ApiComment, ApiIssue, ApiPull, CheckStatus } from "@/lib/code"; | |
| 6 | + | import { ago } from "@/lib/time"; | |
| 7 | + | import { fonts, radius, space, useColors } from "@/theme"; | |
| 8 | + | ||
| 9 | + | /** A pull request's state, as an icon in its colour. */ | |
| 10 | + | export function PullIcon({ pull }: { pull: Pick<ApiPull, "status"> }) { | |
| 11 | + | const colors = useColors(); | |
| 12 | + | if (pull.status === "merged") return <GitMerge size={20} color={colors.merged} />; | |
| 13 | + | if (pull.status === "closed") return <GitPullRequestClosed size={20} color={colors.danger} />; | |
| 14 | + | if (pull.status === "draft") return <GitPullRequestDraft size={20} color={colors.muted} />; | |
| 15 | + | return <GitPullRequest size={20} color={colors.success} />; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export function IssueIcon({ issue }: { issue: Pick<ApiIssue, "state" | "reason"> }) { | |
| 19 | + | const colors = useColors(); | |
| 20 | + | if (issue.state === "open") return <CircleDot size={20} color={colors.success} />; | |
| 21 | + | return issue.reason === "not_planned" ? <CircleX size={20} color={colors.muted} /> : <CircleCheck size={20} color={colors.merged} />; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | export function Checks({ status }: { status: CheckStatus }) { | |
| 25 | + | const colors = useColors(); | |
| 26 | + | if (status === "passed") return <CircleCheck size={16} color={colors.success} accessibilityLabel="Checks passed" />; | |
| 27 | + | if (status === "failed" || status === "errored") return <CircleX size={16} color={colors.danger} accessibilityLabel="Checks failed" />; | |
| 28 | + | if (status === "queued" || status === "running") return <Loader size={16} color={colors.warn} accessibilityLabel="Checks running" />; | |
| 29 | + | return null; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | export function StateBadge({ pull }: { pull: Pick<ApiPull, "status"> }) { | |
| 33 | + | const tone = pull.status === "merged" ? "merged" : pull.status === "closed" ? "danger" : pull.status === "open" ? "success" : "neutral"; | |
| 34 | + | return <Badge label={pull.status[0].toUpperCase() + pull.status.slice(1)} tone={tone} />; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /** Markdown, shown as it was written until the app renders it. */ | |
| 38 | + | export function Body({ text }: { text: string | null }) { | |
| 39 | + | const colors = useColors(); | |
| 40 | + | if (!text?.trim()) { | |
| 41 | + | return ( | |
| 42 | + | <Text tone="faint" style={{ fontStyle: "italic" }}> | |
| 43 | + | No description. | |
| 44 | + | </Text> | |
| 45 | + | ); | |
| 46 | + | } | |
| 47 | + | return ( | |
| 48 | + | <View style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface }]}> | |
| 49 | + | <Text selectable>{text}</Text> | |
| 50 | + | </View> | |
| 51 | + | ); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /** The conversation under an issue or pull request: comments, reviews and what happened. */ | |
| 55 | + | export function Timeline({ comments }: { comments: ApiComment[] }) { | |
| 56 | + | const colors = useColors(); | |
| 57 | + | if (comments.length === 0) return null; | |
| 58 | + | return ( | |
| 59 | + | <View style={{ gap: space.md }}> | |
| 60 | + | {comments.map((comment) => | |
| 61 | + | comment.kind === "event" ? ( | |
| 62 | + | <Text key={comment.id} size="small" tone="muted"> | |
| 63 | + | {comment.author.display_username ?? comment.author.username} {comment.body} · {ago(comment.created_at)} | |
| 64 | + | </Text> | |
| 65 | + | ) : ( | |
| 66 | + | <View key={comment.id} style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface, gap: space.xs }]}> | |
| 67 | + | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}> | |
| 68 | + | <Text style={{ fontWeight: "600" }}>{comment.author.display_username ?? comment.author.username}</Text> | |
| 69 | + | {comment.author.kind === "agent" ? <Badge label="Agent" tone="accent" /> : null} | |
| 70 | + | {comment.verdict ? <Badge label={comment.verdict.replace(/_/g, " ")} tone={comment.verdict === "approve" ? "success" : "warn"} /> : null} | |
| 71 | + | <Text size="small" tone="faint"> | |
| 72 | + | {ago(comment.created_at)} | |
| 73 | + | </Text> | |
| 74 | + | </View> | |
| 75 | + | {comment.path ? ( | |
| 76 | + | <Text size="mono" tone="muted"> | |
| 77 | + | {comment.path} | |
| 78 | + | {comment.line ? `:${comment.line}` : ""} | |
| 79 | + | </Text> | |
| 80 | + | ) : null} | |
| 81 | + | <Text selectable>{comment.body}</Text> | |
| 82 | + | </View> | |
| 83 | + | ), | |
| 84 | + | )} | |
| 85 | + | </View> | |
| 86 | + | ); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /** What a pull request changes, file by file, as unified hunks. */ | |
| 90 | + | export function Diff({ changes }: { changes: ApiChanges }) { | |
| 91 | + | const colors = useColors(); | |
| 92 | + | return ( | |
| 93 | + | <View style={{ gap: space.lg }}> | |
| 94 | + | {changes.files.map((file) => ( | |
| 95 | + | <View key={file.path} style={[styles.file, { borderColor: colors.line }]}> | |
| 96 | + | <View style={[styles.fileHeader, { backgroundColor: colors.raised, borderBottomColor: colors.line }]}> | |
| 97 | + | <Text size="mono" style={{ flex: 1 }} numberOfLines={1}> | |
| 98 | + | {file.path} | |
| 99 | + | </Text> | |
| 100 | + | <Text size="mono" tone="success"> | |
| 101 | + | +{file.additions} | |
| 102 | + | </Text> | |
| 103 | + | <Text size="mono" tone="danger"> | |
| 104 | + | −{file.deletions} | |
| 105 | + | </Text> | |
| 106 | + | </View> | |
| 107 | + | {file.binary ? ( | |
| 108 | + | <Text tone="muted" size="small" style={{ padding: space.md }}> | |
| 109 | + | A binary file. | |
| 110 | + | </Text> | |
| 111 | + | ) : ( | |
| 112 | + | <ScrollView horizontal> | |
| 113 | + | <View> | |
| 114 | + | {(file.hunks ?? []).flatMap((hunk, index) => | |
| 115 | + | hunk.lines.map((line, at) => ( | |
| 116 | + | <Text | |
| 117 | + | key={`${index}-${at}`} | |
| 118 | + | style={{ | |
| 119 | + | fontFamily: fonts.mono, | |
| 120 | + | fontSize: 12, | |
| 121 | + | lineHeight: 18, | |
| 122 | + | paddingHorizontal: space.sm, | |
| 123 | + | color: line.kind === "add" ? colors.success : line.kind === "delete" ? colors.danger : colors.fgSoft, | |
| 124 | + | backgroundColor: line.kind === "add" ? colors.successLow : "transparent", | |
| 125 | + | }} | |
| 126 | + | > | |
| 127 | + | {line.kind === "add" ? "+ " : line.kind === "delete" ? "− " : " "} | |
| 128 | + | {line.text} | |
| 129 | + | </Text> | |
| 130 | + | )), | |
| 131 | + | )} | |
| 132 | + | </View> | |
| 133 | + | </ScrollView> | |
| 134 | + | )} | |
| 135 | + | </View> | |
| 136 | + | ))} | |
| 137 | + | {changes.truncated ? ( | |
| 138 | + | <Text tone="muted" size="small"> | |
| 139 | + | Too much changed to show here. Open it on g1t for the rest. | |
| 140 | + | </Text> | |
| 141 | + | ) : null} | |
| 142 | + | </View> | |
| 143 | + | ); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | const styles = StyleSheet.create({ | |
| 147 | + | box: { borderWidth: 1, borderRadius: radius.md, padding: space.md }, | |
| 148 | + | file: { borderWidth: 1, borderRadius: radius.md, overflow: "hidden" }, | |
| 149 | + | fileHeader: { flexDirection: "row", gap: space.sm, padding: space.sm, borderBottomWidth: StyleSheet.hairlineWidth }, | |
| 150 | + | }); |
| 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 | + | } |
| 1 | + | import Svg, { Rect } from "react-native-svg"; | |
| 2 | + | ||
| 3 | + | import { useColors } from "@/theme"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * The g1t mark, as packages/theme/mark.svg draws it: pixels on an 80 by | |
| 7 | + | * 80 grid, in the accent. | |
| 8 | + | */ | |
| 9 | + | const PIXELS: [number, number][] = [ | |
| 10 | + | [36, 6], | |
| 11 | + | [26, 16], | |
| 12 | + | [36, 16], | |
| 13 | + | [16, 26], | |
| 14 | + | [36, 26], | |
| 15 | + | [36, 36], | |
| 16 | + | [36, 46], | |
| 17 | + | [36, 56], | |
| 18 | + | [16, 66], | |
| 19 | + | [26, 66], | |
| 20 | + | [36, 66], | |
| 21 | + | [46, 66], | |
| 22 | + | [56, 66], | |
| 23 | + | ]; | |
| 24 | + | ||
| 25 | + | export function G1tMark({ size = 32 }: { size?: number }) { | |
| 26 | + | const colors = useColors(); | |
| 27 | + | return ( | |
| 28 | + | <Svg width={size} height={size} viewBox="0 0 80 80" accessibilityLabel="g1t"> | |
| 29 | + | {PIXELS.map(([x, y]) => ( | |
| 30 | + | <Rect key={`${x}-${y}`} x={x} y={y} width={8} height={8} fill={colors.accent} /> | |
| 31 | + | ))} | |
| 32 | + | </Svg> | |
| 33 | + | ); | |
| 34 | + | } |
| 1 | + | import { Pressable, StyleSheet, View } from "react-native"; | |
| 2 | + | ||
| 3 | + | import { Text } from "@/components/ui"; | |
| 4 | + | import { radius, space, useColors } from "@/theme"; | |
| 5 | + | ||
| 6 | + | /** A row of choices, one picked: the site's tabs, on a phone. */ | |
| 7 | + | export function Segments<T extends string>({ options, value, onChange }: { options: { value: T; label: string }[]; value: T; onChange: (value: T) => void }) { | |
| 8 | + | const colors = useColors(); | |
| 9 | + | return ( | |
| 10 | + | <View style={[styles.row, { backgroundColor: colors.raised }]}> | |
| 11 | + | {options.map((option) => { | |
| 12 | + | const on = option.value === value; | |
| 13 | + | return ( | |
| 14 | + | <Pressable | |
| 15 | + | key={option.value} | |
| 16 | + | accessibilityRole="tab" | |
| 17 | + | accessibilityState={{ selected: on }} | |
| 18 | + | onPress={() => onChange(option.value)} | |
| 19 | + | style={[styles.option, on && { backgroundColor: colors.surface, borderColor: colors.lineStrong }]} | |
| 20 | + | > | |
| 21 | + | <Text size="small" style={{ fontWeight: "600", color: on ? colors.fg : colors.muted }}> | |
| 22 | + | {option.label} | |
| 23 | + | </Text> | |
| 24 | + | </Pressable> | |
| 25 | + | ); | |
| 26 | + | })} | |
| 27 | + | </View> | |
| 28 | + | ); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | const styles = StyleSheet.create({ | |
| 32 | + | row: { flexDirection: "row", borderRadius: radius.md, padding: 3, gap: 3 }, | |
| 33 | + | option: { flex: 1, alignItems: "center", paddingVertical: space.sm, borderRadius: radius.sm, borderWidth: 1, borderColor: "transparent" }, | |
| 34 | + | }); |
| 1 | + | import { Stack } from "expo-router"; | |
| 2 | + | ||
| 3 | + | import { useColors } from "@/theme"; | |
| 4 | + | ||
| 5 | + | /** A tab's stack of screens, with g1t's header. */ | |
| 6 | + | export function TabStack() { | |
| 7 | + | const colors = useColors(); | |
| 8 | + | return ( | |
| 9 | + | <Stack | |
| 10 | + | screenOptions={{ | |
| 11 | + | headerStyle: { backgroundColor: colors.surface }, | |
| 12 | + | headerTintColor: colors.fg, | |
| 13 | + | headerTitleStyle: { color: colors.fg }, | |
| 14 | + | headerShadowVisible: false, | |
| 15 | + | contentStyle: { backgroundColor: colors.bg }, | |
| 16 | + | }} | |
| 17 | + | /> | |
| 18 | + | ); | |
| 19 | + | } |
| 1 | + | import { Image } from "expo-image"; | |
| 2 | + | import type { LucideIcon } from "lucide-react-native"; | |
| 3 | + | import { ChevronRight } from "lucide-react-native"; | |
| 4 | + | import type { ReactNode } from "react"; | |
| 5 | + | import { | |
| 6 | + | ActivityIndicator, | |
| 7 | + | Pressable, | |
| 8 | + | type PressableProps, | |
| 9 | + | ScrollView, | |
| 10 | + | StyleSheet, | |
| 11 | + | Text as NativeText, | |
| 12 | + | type TextProps, | |
| 13 | + | TextInput, | |
| 14 | + | type TextInputProps, | |
| 15 | + | View, | |
| 16 | + | type ViewProps, | |
| 17 | + | } from "react-native"; | |
| 18 | + | import { SafeAreaView } from "react-native-safe-area-context"; | |
| 19 | + | ||
| 20 | + | import { ApiError } from "@/lib/client"; | |
| 21 | + | import { fonts, radius, space, useColors } from "@/theme"; | |
| 22 | + | ||
| 23 | + | /** | |
| 24 | + | * The app's building blocks, themed with g1t's tokens. As on the site | |
| 25 | + | * (apps/web/app/components/ui), a screen is built from these, and a look | |
| 26 | + | * changes through a component's variant, not a style at the call site. | |
| 27 | + | */ | |
| 28 | + | ||
| 29 | + | type Tone = "default" | "muted" | "faint" | "accent" | "danger" | "success"; | |
| 30 | + | type Size = "title" | "heading" | "body" | "small" | "mono"; | |
| 31 | + | ||
| 32 | + | const SIZES: Record<Size, { fontSize: number; lineHeight: number; fontWeight?: "600" | "700" }> = { | |
| 33 | + | title: { fontSize: 26, lineHeight: 32, fontWeight: "700" }, | |
| 34 | + | heading: { fontSize: 17, lineHeight: 22, fontWeight: "600" }, | |
| 35 | + | body: { fontSize: 15, lineHeight: 21 }, | |
| 36 | + | small: { fontSize: 13, lineHeight: 18 }, | |
| 37 | + | mono: { fontSize: 12, lineHeight: 17 }, | |
| 38 | + | }; | |
| 39 | + | ||
| 40 | + | export function Text({ size = "body", tone = "default", style, ...props }: TextProps & { size?: Size; tone?: Tone }) { | |
| 41 | + | const colors = useColors(); | |
| 42 | + | const color = { default: colors.fg, muted: colors.muted, faint: colors.faint, accent: colors.accent, danger: colors.danger, success: colors.success }[tone]; | |
| 43 | + | return <NativeText style={[SIZES[size], size === "mono" && { fontFamily: fonts.mono }, { color }, style]} {...props} />; | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /** A screen's background, inside the safe area when it has no header above it. */ | |
| 47 | + | export function Screen({ children, scroll, safe, style, ...props }: ViewProps & { scroll?: boolean; safe?: boolean }) { | |
| 48 | + | const colors = useColors(); | |
| 49 | + | const body = scroll ? ( | |
| 50 | + | <ScrollView contentContainerStyle={[styles.padded, style]} keyboardShouldPersistTaps="handled"> | |
| 51 | + | {children} | |
| 52 | + | </ScrollView> | |
| 53 | + | ) : ( | |
| 54 | + | <View style={[{ flex: 1 }, style]} {...props}> | |
| 55 | + | {children} | |
| 56 | + | </View> | |
| 57 | + | ); | |
| 58 | + | return safe ? ( | |
| 59 | + | <SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }}>{body}</SafeAreaView> | |
| 60 | + | ) : ( | |
| 61 | + | <View style={{ flex: 1, backgroundColor: colors.bg }}>{body}</View> | |
| 62 | + | ); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | export function Button({ | |
| 66 | + | title, | |
| 67 | + | variant = "primary", | |
| 68 | + | busy, | |
| 69 | + | disabled, | |
| 70 | + | icon: Icon, | |
| 71 | + | ...props | |
| 72 | + | }: PressableProps & { title: string; variant?: "primary" | "secondary" | "danger"; busy?: boolean; icon?: LucideIcon }) { | |
| 73 | + | const colors = useColors(); | |
| 74 | + | const look = { | |
| 75 | + | primary: { bg: colors.accent, fg: colors.onAccent, border: colors.accent }, | |
| 76 | + | secondary: { bg: colors.surface, fg: colors.fg, border: colors.lineStrong }, | |
| 77 | + | danger: { bg: colors.surface, fg: colors.danger, border: colors.lineStrong }, | |
| 78 | + | }[variant]; | |
| 79 | + | const off = disabled || busy; | |
| 80 | + | return ( | |
| 81 | + | <Pressable | |
| 82 | + | accessibilityRole="button" | |
| 83 | + | disabled={off} | |
| 84 | + | style={({ pressed }) => [styles.button, { backgroundColor: look.bg, borderColor: look.border, opacity: off ? 0.55 : pressed ? 0.8 : 1 }]} | |
| 85 | + | {...props} | |
| 86 | + | > | |
| 87 | + | {busy ? <ActivityIndicator color={look.fg} /> : Icon ? <Icon size={18} color={look.fg} /> : null} | |
| 88 | + | <Text style={{ color: look.fg, fontWeight: "600" }}>{title}</Text> | |
| 89 | + | </Pressable> | |
| 90 | + | ); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | export function Field({ label, hint, ...props }: TextInputProps & { label: string; hint?: string }) { | |
| 94 | + | const colors = useColors(); | |
| 95 | + | return ( | |
| 96 | + | <View style={{ gap: space.xs }}> | |
| 97 | + | <Text size="small" tone="muted"> | |
| 98 | + | {label} | |
| 99 | + | </Text> | |
| 100 | + | <TextInput | |
| 101 | + | placeholderTextColor={colors.faint} | |
| 102 | + | autoCapitalize="none" | |
| 103 | + | autoCorrect={false} | |
| 104 | + | style={[styles.input, { color: colors.fg, backgroundColor: colors.surface, borderColor: colors.lineStrong }]} | |
| 105 | + | {...props} | |
| 106 | + | /> | |
| 107 | + | {hint ? ( | |
| 108 | + | <Text size="small" tone="faint"> | |
| 109 | + | {hint} | |
| 110 | + | </Text> | |
| 111 | + | ) : null} | |
| 112 | + | </View> | |
| 113 | + | ); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /** A row in a list: what it is, a line under it, and what is at its end. */ | |
| 117 | + | export function Row({ | |
| 118 | + | title, | |
| 119 | + | subtitle, | |
| 120 | + | leading, | |
| 121 | + | trailing, | |
| 122 | + | chevron = true, | |
| 123 | + | onPress, | |
| 124 | + | }: { | |
| 125 | + | title: ReactNode; | |
| 126 | + | subtitle?: ReactNode; | |
| 127 | + | leading?: ReactNode; | |
| 128 | + | trailing?: ReactNode; | |
| 129 | + | chevron?: boolean; | |
| 130 | + | onPress?: () => void; | |
| 131 | + | }) { | |
| 132 | + | const colors = useColors(); | |
| 133 | + | return ( | |
| 134 | + | <Pressable | |
| 135 | + | onPress={onPress} | |
| 136 | + | disabled={!onPress} | |
| 137 | + | style={({ pressed }) => [styles.row, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]} | |
| 138 | + | > | |
| 139 | + | {leading} | |
| 140 | + | <View style={{ flex: 1, gap: 2 }}> | |
| 141 | + | {typeof title === "string" ? ( | |
| 142 | + | <Text numberOfLines={1} style={{ fontWeight: "600" }}> | |
| 143 | + | {title} | |
| 144 | + | </Text> | |
| 145 | + | ) : ( | |
| 146 | + | title | |
| 147 | + | )} | |
| 148 | + | {typeof subtitle === "string" ? ( | |
| 149 | + | <Text size="small" tone="muted" numberOfLines={2}> | |
| 150 | + | {subtitle} | |
| 151 | + | </Text> | |
| 152 | + | ) : ( | |
| 153 | + | subtitle | |
| 154 | + | )} | |
| 155 | + | </View> | |
| 156 | + | {trailing} | |
| 157 | + | {onPress && chevron ? <ChevronRight size={18} color={colors.faint} /> : null} | |
| 158 | + | </Pressable> | |
| 159 | + | ); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | export function SectionHeader({ title }: { title: string }) { | |
| 163 | + | const colors = useColors(); | |
| 164 | + | return ( | |
| 165 | + | <View style={[styles.sectionHeader, { backgroundColor: colors.bg }]}> | |
| 166 | + | <Text size="small" tone="muted" style={{ fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.6 }}> | |
| 167 | + | {title} | |
| 168 | + | </Text> | |
| 169 | + | </View> | |
| 170 | + | ); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | type BadgeTone = "neutral" | "accent" | "success" | "danger" | "merged" | "warn"; | |
| 174 | + | ||
| 175 | + | export function Badge({ label, tone = "neutral" }: { label: string; tone?: BadgeTone }) { | |
| 176 | + | const colors = useColors(); | |
| 177 | + | const fg = { neutral: colors.muted, accent: colors.accent, success: colors.success, danger: colors.danger, merged: colors.merged, warn: colors.warn }[tone]; | |
| 178 | + | return ( | |
| 179 | + | <View style={[styles.badge, { borderColor: fg }]}> | |
| 180 | + | <Text size="small" style={{ color: fg, fontWeight: "600" }}> | |
| 181 | + | {label} | |
| 182 | + | </Text> | |
| 183 | + | </View> | |
| 184 | + | ); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /** | |
| 188 | + | * A person's or an agent's picture: the uploaded one when there is one, | |
| 189 | + | * else their initial. Agents carry a marker, as everywhere on g1t. The | |
| 190 | + | * site draws an agent's own bot face (components/agent-face.tsx); bringing | |
| 191 | + | * that here is to come. | |
| 192 | + | */ | |
| 193 | + | export function Avatar({ name, uri, agent, size = 32 }: { name: string; uri?: string | null; agent?: boolean; size?: number }) { | |
| 194 | + | const colors = useColors(); | |
| 195 | + | const corner = agent ? radius.sm : size / 2; | |
| 196 | + | return ( | |
| 197 | + | <View accessibilityLabel={agent ? `${name}, agent` : name} style={{ width: size, height: size }}> | |
| 198 | + | {uri ? ( | |
| 199 | + | <Image source={{ uri }} style={{ width: size, height: size, borderRadius: corner }} /> | |
| 200 | + | ) : ( | |
| 201 | + | <View style={{ width: size, height: size, borderRadius: corner, backgroundColor: colors.accentLow, alignItems: "center", justifyContent: "center" }}> | |
| 202 | + | <Text style={{ color: colors.accent, fontWeight: "700", fontSize: size * 0.42, lineHeight: size * 0.5 }}>{name.slice(0, 1).toUpperCase()}</Text> | |
| 203 | + | </View> | |
| 204 | + | )} | |
| 205 | + | {agent ? <View style={[styles.agentMarker, { backgroundColor: colors.accent, borderColor: colors.bg }]} /> : null} | |
| 206 | + | </View> | |
| 207 | + | ); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | export function Loading() { | |
| 211 | + | const colors = useColors(); | |
| 212 | + | return ( | |
| 213 | + | <View style={styles.center}> | |
| 214 | + | <ActivityIndicator color={colors.accent} /> | |
| 215 | + | </View> | |
| 216 | + | ); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | export function Empty({ icon: Icon, title, body, action }: { icon?: LucideIcon; title: string; body?: string; action?: ReactNode }) { | |
| 220 | + | const colors = useColors(); | |
| 221 | + | return ( | |
| 222 | + | <View style={[styles.center, { gap: space.sm, padding: space.xl }]}> | |
| 223 | + | {Icon ? <Icon size={28} color={colors.faint} /> : null} | |
| 224 | + | <Text size="heading" style={{ textAlign: "center" }}> | |
| 225 | + | {title} | |
| 226 | + | </Text> | |
| 227 | + | {body ? ( | |
| 228 | + | <Text tone="muted" style={{ textAlign: "center" }}> | |
| 229 | + | {body} | |
| 230 | + | </Text> | |
| 231 | + | ) : null} | |
| 232 | + | {action} | |
| 233 | + | </View> | |
| 234 | + | ); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /** What went wrong, in words, with a way to try again. */ | |
| 238 | + | export function ErrorView({ error, retry }: { error: unknown; retry?: () => void }) { | |
| 239 | + | const message = error instanceof Error ? error.message : "Something went wrong."; | |
| 240 | + | const title = error instanceof ApiError && error.code === "website_refused" ? "Chat needs another sign-in" : "Could not load this"; | |
| 241 | + | return <Empty title={title} body={message} action={retry ? <Button title="Try again" variant="secondary" onPress={retry} /> : null} />; | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | const styles = StyleSheet.create({ | |
| 245 | + | padded: { padding: space.lg, gap: space.lg }, | |
| 246 | + | button: { | |
| 247 | + | minHeight: 46, | |
| 248 | + | borderRadius: radius.md, | |
| 249 | + | borderWidth: 1, | |
| 250 | + | paddingHorizontal: space.lg, | |
| 251 | + | flexDirection: "row", | |
| 252 | + | alignItems: "center", | |
| 253 | + | justifyContent: "center", | |
| 254 | + | gap: space.sm, | |
| 255 | + | }, | |
| 256 | + | input: { minHeight: 46, borderRadius: radius.md, borderWidth: 1, paddingHorizontal: space.md, fontSize: 15 }, | |
| 257 | + | row: { | |
| 258 | + | flexDirection: "row", | |
| 259 | + | alignItems: "center", | |
| 260 | + | gap: space.md, | |
| 261 | + | paddingHorizontal: space.lg, | |
| 262 | + | paddingVertical: space.md, | |
| 263 | + | borderBottomWidth: StyleSheet.hairlineWidth, | |
| 264 | + | }, | |
| 265 | + | sectionHeader: { paddingHorizontal: space.lg, paddingTop: space.lg, paddingBottom: space.sm }, | |
| 266 | + | badge: { borderWidth: 1, borderRadius: radius.full, paddingHorizontal: space.sm, paddingVertical: 1 }, | |
| 267 | + | agentMarker: { position: "absolute", right: -2, bottom: -2, width: 10, height: 10, borderRadius: 3, borderWidth: 2 }, | |
| 268 | + | center: { flex: 1, alignItems: "center", justifyContent: "center" }, | |
| 269 | + | }); |
| 1 | + | import type { Channel, ChatLiveEvent, ChatMessage, ChatSidebar, MemberProfile, MessagePage } from "@g1t/contracts"; | |
| 2 | + | import { type InfiniteData, type QueryClient, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | + | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 4 | + | ||
| 5 | + | import type { Client } from "./client"; | |
| 6 | + | import { openSocket } from "./live"; | |
| 7 | + | import { useSignedIn } from "./session"; | |
| 8 | + | ||
| 9 | + | /** | |
| 10 | + | * Chat, through the site's routes, as the site's own Chat pages use them | |
| 11 | + | * (apps/web/app/routes/workspace/chat/api.ts): | |
| 12 | + | * | |
| 13 | + | * - `GET /<workspace>/-/chat/api`: the sidebar. | |
| 14 | + | * - `GET /<workspace>/-/chat/api?channel=<id>[&thread=<id>]&before=<id>`: a page of messages. | |
| 15 | + | * - `POST /<workspace>/-/chat/api` with `{ intent, … }`: everything sent. | |
| 16 | + | * - `wss://<site>/<workspace>/-/chat/live?channel=<id>`: what happens in a | |
| 17 | + | * conversation, live, and the one thing a client says there: typing. | |
| 18 | + | * | |
| 19 | + | * The app signs in with `chat:read` and `chat:write`, which the site takes | |
| 20 | + | * on these routes alone. | |
| 21 | + | */ | |
| 22 | + | ||
| 23 | + | const chatPath = (workspace: string) => `/${encodeURIComponent(workspace)}/-/chat/api`; | |
| 24 | + | ||
| 25 | + | export const chatKeys = { | |
| 26 | + | all: (workspace: string) => ["chat", workspace] as const, | |
| 27 | + | sidebar: (workspace: string) => ["chat", workspace, "sidebar"] as const, | |
| 28 | + | browse: (workspace: string) => ["chat", workspace, "browse"] as const, | |
| 29 | + | messages: (workspace: string, channel: string, thread: string | null) => ["chat", workspace, "messages", channel, thread] as const, | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | /** A message as the app keeps it: a sent one may still be on its way, or have failed. */ | |
| 33 | + | export type Shown = ChatMessage & { local?: "sending" | "failed" }; | |
| 34 | + | ||
| 35 | + | type Pages = InfiniteData<MessagePage & { messages: Shown[] }, string | null>; | |
| 36 | + | ||
| 37 | + | /** Posts an intent to the chat route and returns its value. */ | |
| 38 | + | export function sendIntent<T>(client: Client, workspace: string, intent: string, body: Record<string, unknown>): Promise<T> { | |
| 39 | + | return client.site<T>(chatPath(workspace), { method: "POST", body: JSON.stringify({ intent, ...body }) }); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // ── Reading ──────────────────────────────────────────────────────────────── | |
| 43 | + | ||
| 44 | + | export function useChatSidebar() { | |
| 45 | + | const { client, workspace } = useSignedIn(); | |
| 46 | + | const slug = workspace?.slug ?? ""; | |
| 47 | + | return useQuery({ | |
| 48 | + | queryKey: chatKeys.sidebar(slug), | |
| 49 | + | queryFn: () => client.site<ChatSidebar>(chatPath(slug)), | |
| 50 | + | enabled: !!slug, | |
| 51 | + | }); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /** The workspace's channels, to find and join (`GET …/chat/api?browse=1`). */ | |
| 55 | + | export function useBrowseChannels() { | |
| 56 | + | const { client, workspace } = useSignedIn(); | |
| 57 | + | const slug = workspace?.slug ?? ""; | |
| 58 | + | return useQuery({ | |
| 59 | + | queryKey: chatKeys.browse(slug), | |
| 60 | + | queryFn: () => client.site<Channel[]>(`${chatPath(slug)}?browse=1`), | |
| 61 | + | enabled: !!slug, | |
| 62 | + | }); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | export function useMessages(channelId: string, thread: string | null = null) { | |
| 66 | + | const { client, workspace } = useSignedIn(); | |
| 67 | + | const slug = workspace?.slug ?? ""; | |
| 68 | + | return useInfiniteQuery({ | |
| 69 | + | queryKey: chatKeys.messages(slug, channelId, thread), | |
| 70 | + | initialPageParam: null as string | null, | |
| 71 | + | queryFn: ({ pageParam }) => { | |
| 72 | + | const query = new URLSearchParams({ channel: channelId, limit: "50" }); | |
| 73 | + | if (thread) query.set("thread", thread); | |
| 74 | + | if (pageParam) query.set("before", pageParam); | |
| 75 | + | return client.site<MessagePage>(`${chatPath(slug)}?${query}`); | |
| 76 | + | }, | |
| 77 | + | getNextPageParam: (page) => page.older, | |
| 78 | + | enabled: !!slug && !!channelId, | |
| 79 | + | }); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // ── The cache, as messages arrive ─────────────────────────────────────────── | |
| 83 | + | ||
| 84 | + | function updatePages(queries: QueryClient, key: readonly unknown[], change: (messages: Shown[], index: number) => Shown[]) { | |
| 85 | + | queries.setQueryData<Pages>(key, (data) => (data ? { ...data, pages: data.pages.map((page, index) => ({ ...page, messages: change(page.messages, index) })) } : data)); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /** A message, put where it belongs: replacing itself, or its local copy, or added as the newest. */ | |
| 89 | + | export function upsertMessage(queries: QueryClient, workspace: string, message: Shown, localId?: string) { | |
| 90 | + | const key = chatKeys.messages(workspace, message.channel_id, message.thread_root ?? null); | |
| 91 | + | const data = queries.getQueryData<Pages>(key); | |
| 92 | + | const had = !!data?.pages.some((page) => page.messages.some((one) => one.id === message.id)); | |
| 93 | + | const hadLocal = !!localId && !!data?.pages.some((page) => page.messages.some((one) => one.id === localId)); | |
| 94 | + | if (data) { | |
| 95 | + | if (had || hadLocal) { | |
| 96 | + | updatePages(queries, key, (messages) => { | |
| 97 | + | const replaced = messages.map((one) => (one.id === message.id || (localId && one.id === localId) ? message : one)); | |
| 98 | + | // A local copy and the real one may both have arrived: keep one. | |
| 99 | + | return replaced.filter((one, index) => replaced.findIndex((other) => other.id === one.id) === index); | |
| 100 | + | }); | |
| 101 | + | } else { | |
| 102 | + | updatePages(queries, key, (messages, index) => (index === 0 ? [...messages, message] : messages)); | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | // A reply new to the app also counts on its root, in the conversation. | |
| 106 | + | if (message.thread_root && !message.local && !had) { | |
| 107 | + | updatePages(queries, chatKeys.messages(workspace, message.channel_id, null), (messages) => | |
| 108 | + | messages.map((one) => (one.id === message.thread_root ? { ...one, reply_count: one.reply_count + 1, last_reply_at: message.created_at } : one)), | |
| 109 | + | ); | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | function removeMessage(queries: QueryClient, workspace: string, channelId: string, id: string) { | |
| 114 | + | for (const [key] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", channelId] })) { | |
| 115 | + | updatePages(queries, key, (messages) => messages.filter((one) => one.id !== id)); | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | function patchMessage(queries: QueryClient, workspace: string, channelId: string, id: string, change: (message: Shown) => Shown) { | |
| 120 | + | for (const [key] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", channelId] })) { | |
| 121 | + | updatePages(queries, key, (messages) => messages.map((one) => (one.id === id ? change(one) : one))); | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /** A reaction added or taken away, by the viewer (`mine`) or someone else, on a message in the cache. */ | |
| 126 | + | function applyReaction(queries: QueryClient, workspace: string, channelId: string, messageId: string, emoji: string, member: MemberProfile | null, added: boolean, mine: boolean) { | |
| 127 | + | patchMessage(queries, workspace, channelId, messageId, (message) => { | |
| 128 | + | const reactions = [...(message.reactions ?? [])]; | |
| 129 | + | const at = reactions.findIndex((one) => one.emoji === emoji); | |
| 130 | + | if (at === -1) { | |
| 131 | + | if (!added) return message; | |
| 132 | + | reactions.push({ emoji, count: 1, me: mine, by: member ? [member] : [] }); | |
| 133 | + | } else { | |
| 134 | + | const one = reactions[at]; | |
| 135 | + | if (mine && one.me === added) return message; | |
| 136 | + | const count = one.count + (added ? 1 : -1); | |
| 137 | + | if (count <= 0) reactions.splice(at, 1); | |
| 138 | + | else | |
| 139 | + | reactions[at] = { | |
| 140 | + | ...one, | |
| 141 | + | count, | |
| 142 | + | me: mine ? added : one.me, | |
| 143 | + | by: member ? (added ? [...one.by, member] : one.by.filter((m) => m.id !== member.id)) : one.by, | |
| 144 | + | }; | |
| 145 | + | } | |
| 146 | + | return { ...message, reactions }; | |
| 147 | + | }); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /** | |
| 151 | + | * Live events carry every reaction's `me` as false, since the whole room | |
| 152 | + | * gets them: keep the viewer's own from the copy the app has. | |
| 153 | + | */ | |
| 154 | + | function keepMine(queries: QueryClient, workspace: string, message: ChatMessage): ChatMessage { | |
| 155 | + | for (const [, data] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", message.channel_id] })) { | |
| 156 | + | const had = data?.pages.flatMap((page) => page.messages).find((one) => one.id === message.id); | |
| 157 | + | if (had) { | |
| 158 | + | const mine = new Set((had.reactions ?? []).filter((one) => one.me).map((one) => one.emoji)); | |
| 159 | + | return { ...message, reactions: (message.reactions ?? []).map((one) => ({ ...one, me: mine.has(one.emoji) })) }; | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | return message; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // ── Sending ──────────────────────────────────────────────────────────────── | |
| 166 | + | ||
| 167 | + | let localCounter = 0; | |
| 168 | + | ||
| 169 | + | /** | |
| 170 | + | * Sends a message. It shows at once, marked as sending, and becomes the | |
| 171 | + | * real one when the site answers (or when the live socket brings it, | |
| 172 | + | * whichever is first); a failed one stays, marked, to send again. | |
| 173 | + | */ | |
| 174 | + | export function useSend(channelId: string, thread: string | null = null) { | |
| 175 | + | const { client, workspace, me } = useSignedIn(); | |
| 176 | + | const queries = useQueryClient(); | |
| 177 | + | const slug = workspace?.slug ?? ""; | |
| 178 | + | ||
| 179 | + | return useCallback( | |
| 180 | + | (body: string, retryOf?: Shown) => { | |
| 181 | + | const localId = retryOf?.id ?? `local-${Date.now()}-${localCounter++}`; | |
| 182 | + | const local: Shown = { | |
| 183 | + | id: localId, | |
| 184 | + | channel_id: channelId, | |
| 185 | + | author: { | |
| 186 | + | kind: "user", | |
| 187 | + | id: me.id, | |
| 188 | + | name: me.username, | |
| 189 | + | display_username: me.display_username ?? null, | |
| 190 | + | display_name: me.display_username ?? me.username, | |
| 191 | + | avatar: me.avatar ?? null, | |
| 192 | + | role: null, | |
| 193 | + | } as MemberProfile, | |
| 194 | + | kind: "text", | |
| 195 | + | body, | |
| 196 | + | card: null, | |
| 197 | + | thread_root: thread, | |
| 198 | + | reply_count: 0, | |
| 199 | + | last_reply_at: null, | |
| 200 | + | created_at: new Date().toISOString(), | |
| 201 | + | edited_at: null, | |
| 202 | + | deleted_at: null, | |
| 203 | + | reactions: [], | |
| 204 | + | local: "sending", | |
| 205 | + | }; | |
| 206 | + | upsertMessage(queries, slug, local, localId); | |
| 207 | + | sendIntent<ChatMessage>(client, slug, "post", { channel_id: channelId, body, thread_root: thread }) | |
| 208 | + | .then((message) => { | |
| 209 | + | upsertMessage(queries, slug, message, localId); | |
| 210 | + | void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }); | |
| 211 | + | }) | |
| 212 | + | .catch(() => upsertMessage(queries, slug, { ...local, local: "failed" }, localId)); | |
| 213 | + | }, | |
| 214 | + | [client, queries, slug, channelId, thread, me], | |
| 215 | + | ); | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /** Everything else a person does to messages and conversations. */ | |
| 219 | + | export function useChatActions(channelId: string) { | |
| 220 | + | const { client, workspace } = useSignedIn(); | |
| 221 | + | const queries = useQueryClient(); | |
| 222 | + | const slug = workspace?.slug ?? ""; | |
| 223 | + | ||
| 224 | + | const react = useMutation({ | |
| 225 | + | mutationFn: ({ message, emoji, add }: { message: ChatMessage; emoji: string; add: boolean }) => | |
| 226 | + | sendIntent(client, slug, add ? "react" : "unreact", { channel_id: channelId, message_id: message.id, emoji }), | |
| 227 | + | onMutate: ({ message, emoji, add }) => applyReaction(queries, slug, channelId, message.id, emoji, null, add, true), | |
| 228 | + | onError: (_error, { message, emoji, add }) => applyReaction(queries, slug, channelId, message.id, emoji, null, !add, true), | |
| 229 | + | }); | |
| 230 | + | ||
| 231 | + | const edit = useMutation({ | |
| 232 | + | mutationFn: ({ message, body }: { message: ChatMessage; body: string }) => sendIntent<ChatMessage>(client, slug, "edit", { channel_id: channelId, id: message.id, body }), | |
| 233 | + | onSuccess: (message) => upsertMessage(queries, slug, message), | |
| 234 | + | }); | |
| 235 | + | ||
| 236 | + | const remove = useMutation({ | |
| 237 | + | mutationFn: (message: ChatMessage) => sendIntent(client, slug, "remove", { channel_id: channelId, id: message.id }), | |
| 238 | + | onMutate: (message) => removeMessage(queries, slug, channelId, message.id), | |
| 239 | + | onError: () => queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }), | |
| 240 | + | }); | |
| 241 | + | ||
| 242 | + | /** Presses a button on an agent's card: approve, stop, file. */ | |
| 243 | + | const cardAction = useMutation({ | |
| 244 | + | mutationFn: ({ message, action, input }: { message: ChatMessage; action: string; input?: string | null }) => | |
| 245 | + | sendIntent<{ ok: boolean; message: string | null }>(client, slug, "card_action", { channel_id: channelId, message_id: message.id, action_id: action, input: input ?? null }), | |
| 246 | + | }); | |
| 247 | + | ||
| 248 | + | const preferences = useMutation({ | |
| 249 | + | mutationFn: (change: { starred?: boolean; muted?: boolean }) => sendIntent(client, slug, "preferences", { channel_id: channelId, ...change }), | |
| 250 | + | onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| 251 | + | }); | |
| 252 | + | ||
| 253 | + | const leave = useMutation({ | |
| 254 | + | mutationFn: () => sendIntent(client, slug, "leave", { channel_id: channelId }), | |
| 255 | + | onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| 256 | + | }); | |
| 257 | + | ||
| 258 | + | return { react, edit, remove, cardAction, preferences, leave }; | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | /** Marks a conversation read up to a message; the sidebar's counts follow. */ | |
| 262 | + | export function markRead(client: Client, queries: QueryClient, workspace: string, channelId: string, messageId: string) { | |
| 263 | + | queries.setQueryData<ChatSidebar>(chatKeys.sidebar(workspace), (data) => | |
| 264 | + | data ? { ...data, entries: data.entries.map((entry) => (entry.channel.id === channelId ? { ...entry, unread: 0, mentions: 0 } : entry)) } : data, | |
| 265 | + | ); | |
| 266 | + | return sendIntent(client, workspace, "read", { channel_id: channelId, id: messageId }).catch(() => undefined); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | export function useStartConversation() { | |
| 270 | + | const { client, workspace } = useSignedIn(); | |
| 271 | + | const queries = useQueryClient(); | |
| 272 | + | const slug = workspace?.slug ?? ""; | |
| 273 | + | return { | |
| 274 | + | /** Opens (or finds) a direct message with these members: `user:<username>` or `agent:<id>`. */ | |
| 275 | + | dm: useMutation({ | |
| 276 | + | mutationFn: (members: string[]) => sendIntent<{ channel: Channel }>(client, slug, "dm", { members }), | |
| 277 | + | onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| 278 | + | }), | |
| 279 | + | join: useMutation({ | |
| 280 | + | mutationFn: (channelId: string) => sendIntent(client, slug, "join", { channel_id: channelId }), | |
| 281 | + | onSuccess: () => { | |
| 282 | + | void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }); | |
| 283 | + | void queries.invalidateQueries({ queryKey: chatKeys.browse(slug) }); | |
| 284 | + | }, | |
| 285 | + | }), | |
| 286 | + | create: useMutation({ | |
| 287 | + | mutationFn: (channel: { name: string; topic?: string; private?: boolean }) => sendIntent<{ channel: Channel }>(client, slug, "create_channel", channel), | |
| 288 | + | onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| 289 | + | }), | |
| 290 | + | }; | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // ── Live ─────────────────────────────────────────────────────────────────── | |
| 294 | + | ||
| 295 | + | export type LiveStatus = "connecting" | "live" | "offline"; | |
| 296 | + | ||
| 297 | + | /** Who is typing, until when. */ | |
| 298 | + | type Typing = { member: MemberProfile; until: number }; | |
| 299 | + | ||
| 300 | + | /** | |
| 301 | + | * Keeps a conversation live while its screen is open: applies what its | |
| 302 | + | * socket says to the cache, says who is typing, and lets the composer say | |
| 303 | + | * the viewer is (at most every 3 s, as the room takes it). | |
| 304 | + | */ | |
| 305 | + | export function useChannelLive(channelId: string) { | |
| 306 | + | const { client, workspace, me } = useSignedIn(); | |
| 307 | + | const queries = useQueryClient(); | |
| 308 | + | const slug = workspace?.slug ?? ""; | |
| 309 | + | const [status, setStatus] = useState<LiveStatus>("connecting"); | |
| 310 | + | const [typing, setTyping] = useState<Typing[]>([]); | |
| 311 | + | const send = useRef<((frame: unknown) => void) | null>(null); | |
| 312 | + | const typedAt = useRef(0); | |
| 313 | + | ||
| 314 | + | useEffect(() => { | |
| 315 | + | if (!slug || !channelId) return; | |
| 316 | + | const socket = openSocket(client, `/${encodeURIComponent(slug)}/-/chat/live`, { channel: channelId }, { | |
| 317 | + | onStatus: setStatus, | |
| 318 | + | onReconnect: () => void queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }), | |
| 319 | + | onMessage: (data) => { | |
| 320 | + | const live = data as ChatLiveEvent; | |
| 321 | + | switch (live.type) { | |
| 322 | + | case "message.created": | |
| 323 | + | case "message.updated": | |
| 324 | + | upsertMessage(queries, slug, keepMine(queries, slug, live.message)); | |
| 325 | + | setTyping((now) => now.filter((one) => one.member.id !== live.message.author.id)); | |
| 326 | + | break; | |
| 327 | + | case "message.deleted": | |
| 328 | + | removeMessage(queries, slug, live.channel_id, live.id); | |
| 329 | + | break; | |
| 330 | + | case "reaction.added": | |
| 331 | + | case "reaction.removed": | |
| 332 | + | if (live.member.id !== me.id) applyReaction(queries, slug, live.channel_id, live.message_id, live.emoji, live.member, live.type === "reaction.added", false); | |
| 333 | + | break; | |
| 334 | + | case "typing": | |
| 335 | + | if (live.member.id === me.id) break; | |
| 336 | + | setTyping((now) => [...now.filter((one) => one.member.id !== live.member.id), { member: live.member, until: Date.parse(live.until) }]); | |
| 337 | + | break; | |
| 338 | + | case "channel.updated": | |
| 339 | + | void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }); | |
| 340 | + | break; | |
| 341 | + | } | |
| 342 | + | }, | |
| 343 | + | }); | |
| 344 | + | send.current = socket.send; | |
| 345 | + | return () => { | |
| 346 | + | send.current = null; | |
| 347 | + | socket.close(); | |
| 348 | + | }; | |
| 349 | + | }, [client, queries, slug, channelId, me.id]); | |
| 350 | + | ||
| 351 | + | // Typing lapses on its own. | |
| 352 | + | useEffect(() => { | |
| 353 | + | if (typing.length === 0) return; | |
| 354 | + | const next = Math.min(...typing.map((one) => one.until)) - Date.now(); | |
| 355 | + | const timer = setTimeout(() => setTyping((now) => now.filter((one) => one.until > Date.now())), Math.max(250, next)); | |
| 356 | + | return () => clearTimeout(timer); | |
| 357 | + | }, [typing]); | |
| 358 | + | ||
| 359 | + | const sayTyping = useCallback(() => { | |
| 360 | + | const now = Date.now(); | |
| 361 | + | if (now - typedAt.current < 3000) return; | |
| 362 | + | typedAt.current = now; | |
| 363 | + | send.current?.({ type: "typing", channel_id: channelId }); | |
| 364 | + | }, [channelId]); | |
| 365 | + | ||
| 366 | + | return { status, typing: typing.map((one) => one.member), sayTyping }; | |
| 367 | + | } |
| 1 | + | import { type Credentials, saveCredentials } from "./credentials"; | |
| 2 | + | import { SignedOut, refresh } from "./oauth"; | |
| 3 | + | import type { Server } from "./server"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * Every request the app makes, with its credentials: `api` for the REST | |
| 7 | + | * API (api.g1t.sh), `site` for the site's own JSON routes, which Chat | |
| 8 | + | * lives behind (`/<workspace>/-/chat/api`). Both take | |
| 9 | + | * `Authorization: Bearer g1t_…`. | |
| 10 | + | * | |
| 11 | + | * An OAuth access token is refreshed a minute before it expires, and on a | |
| 12 | + | * 401 once; requests that need a refresh at the same time share it, | |
| 13 | + | * because a refresh token works once. | |
| 14 | + | */ | |
| 15 | + | ||
| 16 | + | export type ErrorCode = | |
| 17 | + | /** Signed out: the session is over and the app returns to sign-in. */ | |
| 18 | + | | "signed_out" | |
| 19 | + | /** | |
| 20 | + | * The site refused the token: an OAuth token, or a personal one without | |
| 21 | + | * "Use the website as you". The API still works; Chat does not. | |
| 22 | + | */ | |
| 23 | + | | "website_refused" | |
| 24 | + | | "not_found" | |
| 25 | + | | "forbidden" | |
| 26 | + | | "offline" | |
| 27 | + | | "failed"; | |
| 28 | + | ||
| 29 | + | export class ApiError extends Error { | |
| 30 | + | constructor( | |
| 31 | + | readonly code: ErrorCode, | |
| 32 | + | message: string, | |
| 33 | + | readonly status = 0, | |
| 34 | + | ) { | |
| 35 | + | super(message); | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | export type Session = { | |
| 40 | + | server: Server; | |
| 41 | + | credentials: Credentials; | |
| 42 | + | /** Told when credentials change (a refresh) or end (signed out). */ | |
| 43 | + | onCredentials: (next: Credentials | null) => void; | |
| 44 | + | }; | |
| 45 | + | ||
| 46 | + | const REFRESH_EARLY_MS = 60_000; | |
| 47 | + | ||
| 48 | + | export class Client { | |
| 49 | + | private refreshing: Promise<Credentials> | null = null; | |
| 50 | + | ||
| 51 | + | constructor(private session: Session) {} | |
| 52 | + | ||
| 53 | + | get server(): Server { | |
| 54 | + | return this.session.server; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | get credentials(): Credentials { | |
| 58 | + | return this.session.credentials; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /** A REST API call: `client.api<Repo[]>("/repos")`. */ | |
| 62 | + | api<T>(path: string, init?: RequestInit): Promise<T> { | |
| 63 | + | return this.json<T>(this.session.server.api + path, init, "api"); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /** | |
| 67 | + | * One of the site's JSON routes. They answer `{ ok, value }` or | |
| 68 | + | * `{ ok: false, error }`, the services' own shape, which this unwraps. | |
| 69 | + | */ | |
| 70 | + | async site<T>(path: string, init?: RequestInit): Promise<T> { | |
| 71 | + | const answer = await this.json<{ ok: true; value: T } | { ok: false; error: { code: string; message: string } }>( | |
| 72 | + | this.session.server.site + path, | |
| 73 | + | init, | |
| 74 | + | "site", | |
| 75 | + | ); | |
| 76 | + | if (!answer.ok) { | |
| 77 | + | const code: ErrorCode = answer.error.code === "not_found" ? "not_found" : answer.error.code === "forbidden" ? "forbidden" : "failed"; | |
| 78 | + | throw new ApiError(code, answer.error.message); | |
| 79 | + | } | |
| 80 | + | return answer.value; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /** One of the site's JSON routes that answers in its own shape, such as `/-/notify`. */ | |
| 84 | + | siteRaw<T>(path: string, init?: RequestInit): Promise<T> { | |
| 85 | + | return this.json<T>(this.session.server.site + path, init, "site"); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /** The current access token, refreshed first when it is about to expire. */ | |
| 89 | + | async token(): Promise<string> { | |
| 90 | + | const credentials = this.session.credentials; | |
| 91 | + | if (credentials.kind === "oauth" && credentials.expiresAt - REFRESH_EARLY_MS < Date.now()) { | |
| 92 | + | return (await this.refreshOnce()).accessToken; | |
| 93 | + | } | |
| 94 | + | return credentials.accessToken; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | private refreshOnce(): Promise<Credentials> { | |
| 98 | + | const credentials = this.session.credentials; | |
| 99 | + | if (credentials.kind !== "oauth") return Promise.resolve(credentials); | |
| 100 | + | this.refreshing ??= refresh(this.session.server, credentials) | |
| 101 | + | .then(async (next) => { | |
| 102 | + | this.session = { ...this.session, credentials: next }; | |
| 103 | + | await saveCredentials(next); | |
| 104 | + | this.session.onCredentials(next); | |
| 105 | + | return next; | |
| 106 | + | }) | |
| 107 | + | .catch((error) => { | |
| 108 | + | if (error instanceof SignedOut) this.session.onCredentials(null); | |
| 109 | + | throw error instanceof SignedOut ? new ApiError("signed_out", error.message, 401) : error; | |
| 110 | + | }) | |
| 111 | + | .finally(() => { | |
| 112 | + | this.refreshing = null; | |
| 113 | + | }); | |
| 114 | + | return this.refreshing; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | private async json<T>(url: string, init: RequestInit | undefined, target: "api" | "site", retried = false): Promise<T> { | |
| 118 | + | const token = await this.token(); | |
| 119 | + | let response: Response; | |
| 120 | + | try { | |
| 121 | + | response = await fetch(url, { | |
| 122 | + | ...init, | |
| 123 | + | headers: { | |
| 124 | + | accept: "application/json", | |
| 125 | + | ...(init?.body ? { "content-type": "application/json" } : {}), | |
| 126 | + | ...init?.headers, | |
| 127 | + | authorization: `Bearer ${token}`, | |
| 128 | + | }, | |
| 129 | + | }); | |
| 130 | + | } catch { | |
| 131 | + | throw new ApiError("offline", "Could not reach g1t. Check your connection."); | |
| 132 | + | } | |
| 133 | + | if (response.status === 401) { | |
| 134 | + | // An expired or revoked OAuth token gets one refresh; the site refusing | |
| 135 | + | // a token it will never take is not that. | |
| 136 | + | const refusedBySite = target === "site" && (await this.siteRefusesToken(response)); | |
| 137 | + | if (refusedBySite) { | |
| 138 | + | throw new ApiError( | |
| 139 | + | "website_refused", | |
| 140 | + | this.session.credentials.kind === "oauth" | |
| 141 | + | ? "Chat is not open to the app's sign-in on this g1t yet. Sign in with an access token that can use the website, from You → Sign in another way." | |
| 142 | + | : "This access token cannot use the website. Turn on “Use the website as you” for it in Settings → Access tokens.", | |
| 143 | + | 401, | |
| 144 | + | ); | |
| 145 | + | } | |
| 146 | + | if (!retried && this.session.credentials.kind === "oauth") { | |
| 147 | + | await this.refreshOnce(); | |
| 148 | + | return this.json<T>(url, init, target, true); | |
| 149 | + | } | |
| 150 | + | this.session.onCredentials(null); | |
| 151 | + | throw new ApiError("signed_out", "You were signed out. Sign in again.", 401); | |
| 152 | + | } | |
| 153 | + | if (response.status === 404) throw new ApiError("not_found", "Not found.", 404); | |
| 154 | + | if (response.status === 403) throw new ApiError("forbidden", await messageOf(response, "You do not have access to this."), 403); | |
| 155 | + | if (!response.ok) throw new ApiError("failed", await messageOf(response, `g1t answered ${response.status}.`), response.status); | |
| 156 | + | return (await response.json()) as T; | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /** | |
| 160 | + | * Whether a 401 from the site is it refusing the token itself (its | |
| 161 | + | * `WWW-Authenticate` names `invalid_token`; apps/web lib/website-token.ts). | |
| 162 | + | * The token may still be good for the API: check before signing out. | |
| 163 | + | */ | |
| 164 | + | private async siteRefusesToken(response: Response): Promise<boolean> { | |
| 165 | + | if (!/invalid_token/.test(response.headers.get("www-authenticate") ?? "")) return false; | |
| 166 | + | try { | |
| 167 | + | const check = await fetch(`${this.session.server.api}/user`, { | |
| 168 | + | headers: { authorization: `Bearer ${this.session.credentials.accessToken}` }, | |
| 169 | + | }); | |
| 170 | + | return check.ok; | |
| 171 | + | } catch { | |
| 172 | + | return false; | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | async function messageOf(response: Response, fallback: string): Promise<string> { | |
| 178 | + | try { | |
| 179 | + | const body = (await response.clone().json()) as { message?: string; error?: { message?: string }; error_description?: string }; | |
| 180 | + | return body.message ?? body.error?.message ?? body.error_description ?? fallback; | |
| 181 | + | } catch { | |
| 182 | + | const text = await response.text().catch(() => ""); | |
| 183 | + | return text.trim().slice(0, 300) || fallback; | |
| 184 | + | } | |
| 185 | + | } |
| 1 | + | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 2 | + | ||
| 3 | + | import { useSignedIn } from "./session"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * Code, through the REST API (docs.g1t.sh/reference/api/). The API | |
| 7 | + | * answers in snake_case, so these are its shapes, not the camelCase ones | |
| 8 | + | * in @g1t/contracts that the services use among themselves. Only the | |
| 9 | + | * fields the app shows are named. | |
| 10 | + | */ | |
| 11 | + | ||
| 12 | + | export type ApiUser = { id: string; username: string; display_username?: string; kind?: "user" | "workspace" | "agent" | "system" }; | |
| 13 | + | ||
| 14 | + | export type ApiRepo = { | |
| 15 | + | id: string; | |
| 16 | + | namespace: string; | |
| 17 | + | name: string; | |
| 18 | + | description: string | null; | |
| 19 | + | is_private: boolean; | |
| 20 | + | default_branch: string; | |
| 21 | + | created_at: string; | |
| 22 | + | archived_at?: string | null; | |
| 23 | + | }; | |
| 24 | + | ||
| 25 | + | export type ApiIssue = { | |
| 26 | + | id: string; | |
| 27 | + | number: number; | |
| 28 | + | title: string; | |
| 29 | + | body: string; | |
| 30 | + | labels: string[]; | |
| 31 | + | state: "open" | "closed"; | |
| 32 | + | reason: "completed" | "not_planned" | null; | |
| 33 | + | author: ApiUser; | |
| 34 | + | created_at: string; | |
| 35 | + | updated_at: string; | |
| 36 | + | comment_count: number; | |
| 37 | + | pull_count: number; | |
| 38 | + | }; | |
| 39 | + | ||
| 40 | + | export type CheckStatus = "queued" | "running" | "passed" | "failed" | "errored" | null; | |
| 41 | + | ||
| 42 | + | export type ApiPull = { | |
| 43 | + | id: string; | |
| 44 | + | number: number; | |
| 45 | + | issue: number | null; | |
| 46 | + | title: string; | |
| 47 | + | body: string | null; | |
| 48 | + | agent: string; | |
| 49 | + | status: "draft" | "open" | "merged" | "closed"; | |
| 50 | + | branch: string | null; | |
| 51 | + | base: string; | |
| 52 | + | check_status: CheckStatus; | |
| 53 | + | files: { path: string; additions: number; deletions: number }[]; | |
| 54 | + | reviewers: string[]; | |
| 55 | + | labels: string[]; | |
| 56 | + | author: ApiUser; | |
| 57 | + | created_at: string; | |
| 58 | + | updated_at: string; | |
| 59 | + | merged_at: string | null; | |
| 60 | + | }; | |
| 61 | + | ||
| 62 | + | export type ApiComment = { | |
| 63 | + | id: string; | |
| 64 | + | kind: "comment" | "event"; | |
| 65 | + | author: ApiUser; | |
| 66 | + | body: string; | |
| 67 | + | path: string | null; | |
| 68 | + | line: number | null; | |
| 69 | + | verdict: string | null; | |
| 70 | + | created_at: string; | |
| 71 | + | }; | |
| 72 | + | ||
| 73 | + | export type ApiChanges = { | |
| 74 | + | base: string; | |
| 75 | + | head: string; | |
| 76 | + | truncated: boolean; | |
| 77 | + | files: { | |
| 78 | + | path: string; | |
| 79 | + | status: "added" | "modified" | "deleted"; | |
| 80 | + | additions: number; | |
| 81 | + | deletions: number; | |
| 82 | + | binary: boolean; | |
| 83 | + | hunks?: { lines: { kind: "context" | "add" | "delete"; old: number | null; new: number | null; text: string }[] }[]; | |
| 84 | + | }[]; | |
| 85 | + | }; | |
| 86 | + | ||
| 87 | + | const repoPath = (owner: string, name: string) => `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; | |
| 88 | + | ||
| 89 | + | /** Repositories you can see, newest first (at most 50), in the workspace chosen. */ | |
| 90 | + | export function useRepos(search: string) { | |
| 91 | + | const { client, workspace } = useSignedIn(); | |
| 92 | + | return useQuery({ | |
| 93 | + | queryKey: ["code", "repos", search], | |
| 94 | + | queryFn: () => client.api<ApiRepo[]>(`/repos${search ? `?q=${encodeURIComponent(search)}` : ""}`), | |
| 95 | + | select: (repos) => (workspace ? repos.filter((repo) => repo.namespace.toLowerCase() === workspace.slug.toLowerCase()) : repos), | |
| 96 | + | }); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | export function useRepo(owner: string, name: string) { | |
| 100 | + | const { client } = useSignedIn(); | |
| 101 | + | return useQuery({ queryKey: ["code", "repo", owner, name], queryFn: () => client.api<ApiRepo>(repoPath(owner, name)) }); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | export function usePulls(owner: string, name: string, state: "open" | "closed" | "all") { | |
| 105 | + | const { client } = useSignedIn(); | |
| 106 | + | return useQuery({ | |
| 107 | + | queryKey: ["code", "pulls", owner, name, state], | |
| 108 | + | queryFn: () => client.api<ApiPull[]>(`${repoPath(owner, name)}/pulls?state=${state}`), | |
| 109 | + | }); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | export function useIssues(owner: string, name: string, state: "open" | "closed" | "all") { | |
| 113 | + | const { client } = useSignedIn(); | |
| 114 | + | return useQuery({ | |
| 115 | + | queryKey: ["code", "issues", owner, name, state], | |
| 116 | + | queryFn: () => client.api<ApiIssue[]>(`${repoPath(owner, name)}/issues?state=${state}`), | |
| 117 | + | }); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | export function usePull(owner: string, name: string, number: number) { | |
| 121 | + | const { client } = useSignedIn(); | |
| 122 | + | return useQuery({ | |
| 123 | + | queryKey: ["code", "pull", owner, name, number], | |
| 124 | + | queryFn: () => | |
| 125 | + | client.api<{ pull: ApiPull; issue: ApiIssue | null; comments: ApiComment[] }>(`${repoPath(owner, name)}/pulls/${number}`), | |
| 126 | + | }); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | export function usePullChanges(owner: string, name: string, number: number, enabled: boolean) { | |
| 130 | + | const { client } = useSignedIn(); | |
| 131 | + | return useQuery({ | |
| 132 | + | queryKey: ["code", "pull-changes", owner, name, number], | |
| 133 | + | queryFn: () => client.api<ApiChanges>(`${repoPath(owner, name)}/pulls/${number}/changes`), | |
| 134 | + | enabled, | |
| 135 | + | }); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | export function useIssue(owner: string, name: string, number: number) { | |
| 139 | + | const { client } = useSignedIn(); | |
| 140 | + | return useQuery({ | |
| 141 | + | queryKey: ["code", "issue", owner, name, number], | |
| 142 | + | queryFn: () => | |
| 143 | + | client.api<{ issue: ApiIssue; pulls: ApiPull[]; comments: ApiComment[] }>(`${repoPath(owner, name)}/issues/${number}`), | |
| 144 | + | }); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /** Comments on an issue (`POST /repos/{owner}/{name}/issues/{number}/comments`). */ | |
| 148 | + | export function useComment(owner: string, name: string, number: number) { | |
| 149 | + | const { client } = useSignedIn(); | |
| 150 | + | const queries = useQueryClient(); | |
| 151 | + | return useMutation({ | |
| 152 | + | mutationFn: (body: string) => | |
| 153 | + | client.api<ApiComment>(`${repoPath(owner, name)}/issues/${number}/comments`, { method: "POST", body: JSON.stringify({ body }) }), | |
| 154 | + | onSuccess: () => queries.invalidateQueries({ queryKey: ["code", "issue", owner, name, number] }), | |
| 155 | + | }); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** Reviews a pull request (`POST /repos/{owner}/{name}/pulls/{number}/reviews`). */ | |
| 159 | + | export function useReview(owner: string, name: string, number: number) { | |
| 160 | + | const { client } = useSignedIn(); | |
| 161 | + | const queries = useQueryClient(); | |
| 162 | + | return useMutation({ | |
| 163 | + | mutationFn: (review: { verdict: "approve" | "request_changes"; body: string }) => | |
| 164 | + | client.api<ApiComment>(`${repoPath(owner, name)}/pulls/${number}/reviews`, { method: "POST", body: JSON.stringify(review) }), | |
| 165 | + | onSuccess: () => queries.invalidateQueries({ queryKey: ["code", "pull", owner, name, number] }), | |
| 166 | + | }); | |
| 167 | + | } |
| 1 | + | import * as SecureStore from "expo-secure-store"; | |
| 2 | + | ||
| 3 | + | import { HOSTED, type Server } from "./server"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * What the app keeps between launches, in the phone's keystore (Android | |
| 7 | + | * Keystore, the iOS keychain): the server, and how it is signed in. | |
| 8 | + | * | |
| 9 | + | * Two ways to be signed in: | |
| 10 | + | * - `oauth`: the app's own sign-in (lib/oauth.ts). An access token that | |
| 11 | + | * lasts 30 days and a refresh token that works once and returns the | |
| 12 | + | * next pair, as every OAuth client of g1t's gets. | |
| 13 | + | * - `token`: a personal access token pasted in, for a g1t whose OAuth | |
| 14 | + | * tokens do not yet open Chat (see the README) or for testing. One with | |
| 15 | + | * "Use the website as you" turned on opens everything the app shows. | |
| 16 | + | */ | |
| 17 | + | export type Credentials = | |
| 18 | + | | { | |
| 19 | + | kind: "oauth"; | |
| 20 | + | accessToken: string; | |
| 21 | + | refreshToken: string; | |
| 22 | + | /** Epoch milliseconds. */ | |
| 23 | + | expiresAt: number; | |
| 24 | + | /** The scopes the person granted, or `*` for full access. */ | |
| 25 | + | scope: string; | |
| 26 | + | /** | |
| 27 | + | * The client id it was issued to: a refresh must name the same one. | |
| 28 | + | * Kept, because in Expo Go the id follows the computer's address, | |
| 29 | + | * which changes between sessions (lib/oauth.ts). | |
| 30 | + | */ | |
| 31 | + | clientId?: string; | |
| 32 | + | } | |
| 33 | + | | { kind: "token"; accessToken: string }; | |
| 34 | + | ||
| 35 | + | const SERVER_KEY = "g1t.server"; | |
| 36 | + | const CREDENTIALS_KEY = "g1t.credentials"; | |
| 37 | + | ||
| 38 | + | async function readJson<T>(key: string): Promise<T | null> { | |
| 39 | + | try { | |
| 40 | + | const text = await SecureStore.getItemAsync(key); | |
| 41 | + | return text ? (JSON.parse(text) as T) : null; | |
| 42 | + | } catch { | |
| 43 | + | // Unreadable (a restore to another phone, a keystore reset): start over. | |
| 44 | + | return null; | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | export async function loadServer(): Promise<Server> { | |
| 49 | + | const saved = await readJson<Server>(SERVER_KEY); | |
| 50 | + | return saved?.site && saved?.api ? saved : HOSTED; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | export async function saveServer(server: Server): Promise<void> { | |
| 54 | + | await SecureStore.setItemAsync(SERVER_KEY, JSON.stringify(server)); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | export async function loadCredentials(): Promise<Credentials | null> { | |
| 58 | + | return readJson<Credentials>(CREDENTIALS_KEY); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | export async function saveCredentials(credentials: Credentials): Promise<void> { | |
| 62 | + | await SecureStore.setItemAsync(CREDENTIALS_KEY, JSON.stringify(credentials)); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | export async function clearCredentials(): Promise<void> { | |
| 66 | + | await SecureStore.deleteItemAsync(CREDENTIALS_KEY); | |
| 67 | + | } |
| 1 | + | import { connectorPath, connectorsFor } from "@g1t/contracts/connectors"; | |
| 2 | + | import { CUSTOMIZE, type CustomizeSection, customizePath } from "@g1t/contracts/customize"; | |
| 3 | + | import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace"; | |
| 4 | + | import { FOUNDATIONAL_SKILLS } from "@g1t/contracts/skills"; | |
| 5 | + | ||
| 6 | + | /** | |
| 7 | + | * Customize on the phone: Skills, Connectors and Plugins | |
| 8 | + | * (packages/contracts/src/customize.ts). What is listed comes from the | |
| 9 | + | * catalogs every g1t ships with; a workspace's own library, connections | |
| 10 | + | * and installs are read on the site for now, and added here once the API | |
| 11 | + | * serves them. | |
| 12 | + | */ | |
| 13 | + | ||
| 14 | + | export type CustomizeItem = { | |
| 15 | + | id: string; | |
| 16 | + | name: string; | |
| 17 | + | description: string; | |
| 18 | + | /** Available, Soon, or what else a listing says about itself. */ | |
| 19 | + | status: "available" | "soon" | "built-in"; | |
| 20 | + | /** A group to list it under. */ | |
| 21 | + | group: string; | |
| 22 | + | /** The site page that sets it up, for this workspace. */ | |
| 23 | + | sitePath: string; | |
| 24 | + | }; | |
| 25 | + | ||
| 26 | + | export function customizeItems(section: CustomizeSection, workspace: string): CustomizeItem[] { | |
| 27 | + | const fallback = customizePath(section, workspace); | |
| 28 | + | if (section === "skills") { | |
| 29 | + | return FOUNDATIONAL_SKILLS.map((skill) => ({ | |
| 30 | + | id: skill.id, | |
| 31 | + | name: skill.name, | |
| 32 | + | description: skill.description, | |
| 33 | + | status: "built-in" as const, | |
| 34 | + | group: "In every agent", | |
| 35 | + | sitePath: fallback, | |
| 36 | + | })); | |
| 37 | + | } | |
| 38 | + | if (section === "connectors") { | |
| 39 | + | return connectorsFor("workspace").map((connector) => ({ | |
| 40 | + | id: connector.id, | |
| 41 | + | name: connector.name, | |
| 42 | + | description: connector.description, | |
| 43 | + | status: connector.status, | |
| 44 | + | group: connector.status === "available" ? "Connect now" : "Coming", | |
| 45 | + | sitePath: connector.href ? connectorPath(connector.href, workspace) : `${fallback}/${encodeURIComponent(connector.id)}`, | |
| 46 | + | })); | |
| 47 | + | } | |
| 48 | + | return FIRST_PARTY_EXTENSIONS.map((extension) => ({ | |
| 49 | + | id: extension.id, | |
| 50 | + | name: extension.name, | |
| 51 | + | description: extension.tagline, | |
| 52 | + | status: extension.status, | |
| 53 | + | group: `${extension.publisher.name} · ${extension.category}`, | |
| 54 | + | sitePath: `${fallback}/${encodeURIComponent(extension.id)}`, | |
| 55 | + | })); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | export { CUSTOMIZE, customizePath, type CustomizeSection }; |
| 1 | + | import type { ChatSidebar, FeedEvent, FeedNotification, Presence } from "@g1t/contracts"; | |
| 2 | + | import { useQueryClient } from "@tanstack/react-query"; | |
| 3 | + | import * as Haptics from "expo-haptics"; | |
| 4 | + | import * as Notifications from "expo-notifications"; | |
| 5 | + | import { usePathname } from "expo-router"; | |
| 6 | + | import { type ReactNode, createContext, useContext, useEffect, useRef, useState } from "react"; | |
| 7 | + | import { AppState } from "react-native"; | |
| 8 | + | ||
| 9 | + | import { chatKeys } from "./chat"; | |
| 10 | + | import { openSocket } from "./live"; | |
| 11 | + | import { useSignedIn } from "./session"; | |
| 12 | + | import { viewing } from "./viewing"; | |
| 13 | + | ||
| 14 | + | /** | |
| 15 | + | * The person's feed, while the app is in front: the same socket every tab | |
| 16 | + | * of the site keeps open (`/-/live?workspace=<slug>`, services/notify). | |
| 17 | + | * It brings unread counts as they move, notifications as they happen (a | |
| 18 | + | * banner unless the conversation is open), and who is online. The app | |
| 19 | + | * says it is focused, so g1t pushes nothing to the phone while it is in | |
| 20 | + | * use; in the background the socket closes and pushes take over. | |
| 21 | + | */ | |
| 22 | + | ||
| 23 | + | type FeedState = { | |
| 24 | + | /** Unread in Chat, leaving out muted conversations; mentions apart. */ | |
| 25 | + | unread: number; | |
| 26 | + | mentions: number; | |
| 27 | + | /** A notification to show as a banner, until it is dismissed. */ | |
| 28 | + | banner: FeedNotification | null; | |
| 29 | + | dismiss: () => void; | |
| 30 | + | presence: Map<string, Presence>; | |
| 31 | + | }; | |
| 32 | + | ||
| 33 | + | const FeedContext = createContext<FeedState>({ unread: 0, mentions: 0, banner: null, dismiss: () => {}, presence: new Map() }); | |
| 34 | + | ||
| 35 | + | export function FeedProvider({ children }: { children: ReactNode }) { | |
| 36 | + | const { client, workspace } = useSignedIn(); | |
| 37 | + | const queries = useQueryClient(); | |
| 38 | + | const slug = workspace?.slug ?? ""; | |
| 39 | + | const path = usePathname(); | |
| 40 | + | const [active, setActive] = useState(AppState.currentState === "active"); | |
| 41 | + | const [counts, setCounts] = useState({ unread: 0, mentions: 0 }); | |
| 42 | + | const [banner, setBanner] = useState<FeedNotification | null>(null); | |
| 43 | + | const [presence, setPresence] = useState<Map<string, Presence>>(new Map()); | |
| 44 | + | const sendRef = useRef<((frame: unknown) => void) | null>(null); | |
| 45 | + | const pathRef = useRef(path); | |
| 46 | + | pathRef.current = path; | |
| 47 | + | ||
| 48 | + | useEffect(() => { | |
| 49 | + | const subscription = AppState.addEventListener("change", (state) => { | |
| 50 | + | // Say so before the socket closes: a phone can freeze it rather than close | |
| 51 | + | // it, and g1t pushes nothing while a socket says it is in front (for up to | |
| 52 | + | // 70 s after it last spoke). | |
| 53 | + | if (state !== "active") sendRef.current?.({ type: "state", focused: false, path: pathRef.current }); | |
| 54 | + | setActive(state === "active"); | |
| 55 | + | }); | |
| 56 | + | return () => subscription.remove(); | |
| 57 | + | }, []); | |
| 58 | + | ||
| 59 | + | useEffect(() => { | |
| 60 | + | if (!active || !slug) return; | |
| 61 | + | const socket = openSocket(client, "/-/live", { workspace: slug }, { | |
| 62 | + | onOpen: (send) => { | |
| 63 | + | sendRef.current = send; | |
| 64 | + | send({ type: "state", focused: true, path: pathRef.current }); | |
| 65 | + | }, | |
| 66 | + | onReconnect: () => void queries.invalidateQueries({ queryKey: chatKeys.all(slug) }), | |
| 67 | + | onMessage: (data) => { | |
| 68 | + | const event = data as FeedEvent; | |
| 69 | + | if (event.type === "counts" && event.workspace === slug) { | |
| 70 | + | setCounts({ unread: event.chat_unread, mentions: event.chat_mentions }); | |
| 71 | + | void Notifications.setBadgeCountAsync(event.chat_unread + event.chat_mentions).catch(() => undefined); | |
| 72 | + | const per = new Map(event.per_channel.map((row) => [row.channel_id, row])); | |
| 73 | + | queries.setQueryData<ChatSidebar>(chatKeys.sidebar(slug), (sidebar) => { | |
| 74 | + | if (!sidebar) return sidebar; | |
| 75 | + | const known = new Set(sidebar.entries.map((entry) => entry.channel.id)); | |
| 76 | + | // A conversation the app has not seen yet: read the sidebar again. | |
| 77 | + | if ([...per.keys()].some((id) => !known.has(id))) void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }); | |
| 78 | + | return { | |
| 79 | + | ...sidebar, | |
| 80 | + | entries: sidebar.entries.map((entry) => { | |
| 81 | + | const row = per.get(entry.channel.id); | |
| 82 | + | if (row) return { ...entry, unread: row.unread, mentions: row.mentions }; | |
| 83 | + | return event.complete ? { ...entry, unread: 0, mentions: 0 } : entry; | |
| 84 | + | }), | |
| 85 | + | }; | |
| 86 | + | }); | |
| 87 | + | } else if (event.type === "notification" && event.toast) { | |
| 88 | + | const note = event.notification; | |
| 89 | + | if (note.workspace !== slug || (note.channel_id && note.channel_id === viewing.channelId)) return; | |
| 90 | + | setBanner(note); | |
| 91 | + | void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => undefined); | |
| 92 | + | } else if (event.type === "presence" && event.workspace === slug) { | |
| 93 | + | setPresence((before) => { | |
| 94 | + | const next = event.full ? new Map<string, Presence>() : new Map(before); | |
| 95 | + | for (const person of event.people) next.set(person.user_id, person.presence); | |
| 96 | + | return next; | |
| 97 | + | }); | |
| 98 | + | } | |
| 99 | + | }, | |
| 100 | + | }); | |
| 101 | + | return () => { | |
| 102 | + | sendRef.current = null; | |
| 103 | + | socket.close(); | |
| 104 | + | }; | |
| 105 | + | }, [active, client, queries, slug]); | |
| 106 | + | ||
| 107 | + | // Where the app is, as a tab says where it is. | |
| 108 | + | useEffect(() => { | |
| 109 | + | sendRef.current?.({ type: "state", focused: true, path }); | |
| 110 | + | }, [path]); | |
| 111 | + | ||
| 112 | + | // A banner goes by itself. | |
| 113 | + | useEffect(() => { | |
| 114 | + | if (!banner) return; | |
| 115 | + | const timer = setTimeout(() => setBanner(null), 5000); | |
| 116 | + | return () => clearTimeout(timer); | |
| 117 | + | }, [banner]); | |
| 118 | + | ||
| 119 | + | return ( | |
| 120 | + | <FeedContext.Provider value={{ ...counts, banner, dismiss: () => setBanner(null), presence }}>{children}</FeedContext.Provider> | |
| 121 | + | ); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | export function useFeed(): FeedState { | |
| 125 | + | return useContext(FeedContext); | |
| 126 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { routeFor, sitePathOf } from "./links.ts"; | |
| 5 | + | ||
| 6 | + | test("a g1t:// link names a site path, as on the desktop", () => { | |
| 7 | + | assert.equal(sitePathOf("g1t://acme/-/chat/dm/chn_1"), "/acme/-/chat/dm/chn_1"); | |
| 8 | + | assert.equal(sitePathOf("g1t:///acme/rocket/pull/3?tab=files"), "/acme/rocket/pull/3?tab=files"); | |
| 9 | + | assert.equal(sitePathOf("https://g1t.sh/acme"), null); | |
| 10 | + | }); | |
| 11 | + | ||
| 12 | + | test("Chat's pages open the app's Chat", () => { | |
| 13 | + | assert.equal(routeFor("/acme/-/chat"), "/chat?workspace=acme"); | |
| 14 | + | assert.equal(routeFor("/acme/-/chat/general"), "/chat/general?workspace=acme"); | |
| 15 | + | assert.equal(routeFor("/acme/-/chat/dm/chn_1"), "/chat/chn_1?workspace=acme"); | |
| 16 | + | assert.equal(routeFor("/acme/-/chat/browse"), null); | |
| 17 | + | }); | |
| 18 | + | ||
| 19 | + | test("Code's pages open the app's Code", () => { | |
| 20 | + | assert.equal(routeFor("/acme/rocket"), "/code/acme/rocket"); | |
| 21 | + | assert.equal(routeFor("/acme/rocket/pull/14"), "/code/acme/rocket/pull/14"); | |
| 22 | + | assert.equal(routeFor("/acme/rocket/issues/12"), "/code/acme/rocket/issues/12"); | |
| 23 | + | assert.equal(routeFor("/acme/-/overview"), "/code?workspace=acme"); | |
| 24 | + | assert.equal(routeFor("/acme/rocket/pull/14/files"), null); | |
| 25 | + | assert.equal(routeFor("/acme/rocket/issues/new"), null); | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | test("the Marketplace, integrations and skills open Customize", () => { | |
| 29 | + | assert.equal(routeFor("/acme/-/marketplace/integrations"), "/customize?workspace=acme"); | |
| 30 | + | assert.equal(routeFor("/acme/-/agents/skills"), "/customize?workspace=acme"); | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | test("the app's sign-in comes back to its own screen, and the site's own pages stay on the site", () => { | |
| 34 | + | assert.equal(routeFor("/oauth/callback?code=c&state=s"), "/oauth/callback?code=c&state=s"); | |
| 35 | + | assert.equal(routeFor("/settings/tokens"), null); | |
| 36 | + | assert.equal(routeFor("/"), "/"); | |
| 37 | + | }); |
| 1 | + | /** | |
| 2 | + | * Links into the app. A `g1t://` link names a site path, as on the desktop | |
| 3 | + | * (apps/desktop/src-tauri/src/links.rs): `g1t://acme/-/chat/dm/chn_1` is | |
| 4 | + | * the page `/acme/-/chat/dm/chn_1`. The app has screens for some of the | |
| 5 | + | * site's pages; `routeFor` says which screen shows a site path, and the | |
| 6 | + | * rest open on the site. | |
| 7 | + | * | |
| 8 | + | * Kept free of React Native imports so `npm test` runs it under Node. | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | /** Where the app's OAuth sign-in comes back to. */ | |
| 12 | + | export const OAUTH_CALLBACK_PATH = "/oauth/callback"; | |
| 13 | + | ||
| 14 | + | /** Path segments a workspace or account can never be named (the site's own pages). */ | |
| 15 | + | const NOT_OWNERS = new Set(["settings", "notifications", "oauth", "device", "login", "register", "-"]); | |
| 16 | + | ||
| 17 | + | /** | |
| 18 | + | * The app's screen for a site path, or null when the app has none and the | |
| 19 | + | * page should open on the site. | |
| 20 | + | */ | |
| 21 | + | export function routeFor(sitePath: string): string | null { | |
| 22 | + | const [pathname, query = ""] = sitePath.split("?", 2); | |
| 23 | + | const parts = pathname.split("/").filter(Boolean).map(decodeURIComponentSafe); | |
| 24 | + | const keep = query ? `?${query}` : ""; | |
| 25 | + | if (parts.length === 0) return "/"; | |
| 26 | + | if (`/${parts.join("/")}` === OAUTH_CALLBACK_PATH) return OAUTH_CALLBACK_PATH + keep; | |
| 27 | + | const [owner, second, third, fourth, fifth] = parts; | |
| 28 | + | if (NOT_OWNERS.has(owner.toLowerCase())) return null; | |
| 29 | + | const workspace = `workspace=${encodeURIComponent(owner)}`; | |
| 30 | + | ||
| 31 | + | // A workspace's own pages: /<workspace>/-/<page>. | |
| 32 | + | if (second === "-") { | |
| 33 | + | if (third === "chat") { | |
| 34 | + | if (!fourth) return `/chat?${workspace}`; | |
| 35 | + | if (fourth === "dm" && fifth) return `/chat/${encodeURIComponent(fifth)}?${workspace}`; | |
| 36 | + | if (fourth !== "browse" && fourth !== "dm" && !fifth) return `/chat/${encodeURIComponent(fourth)}?${workspace}`; | |
| 37 | + | return null; | |
| 38 | + | } | |
| 39 | + | if (third === "overview" && !fourth) return `/code?${workspace}`; | |
| 40 | + | if (third === "marketplace" || third === "integrations" || (third === "agents" && fourth === "skills")) { | |
| 41 | + | return `/customize?${workspace}`; | |
| 42 | + | } | |
| 43 | + | return null; | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // A repository's pages: /<owner>/<repo>[/pull/<n> | /issues/<n>]. | |
| 47 | + | if (!second) return null; | |
| 48 | + | const repo = `/code/${encodeURIComponent(owner)}/${encodeURIComponent(second)}`; | |
| 49 | + | if (!third) return repo; | |
| 50 | + | if (third === "pull" && fourth && /^\d+$/.test(fourth) && !fifth) return `${repo}/pull/${fourth}`; | |
| 51 | + | if (third === "issues" && fourth && /^\d+$/.test(fourth) && !fifth) return `${repo}/issues/${fourth}`; | |
| 52 | + | return null; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /** | |
| 56 | + | * The site path a `g1t://` link names, or null for any other link. | |
| 57 | + | * `g1t://acme/-/chat?x` is `/acme/-/chat?x`. | |
| 58 | + | */ | |
| 59 | + | export function sitePathOf(link: string): string | null { | |
| 60 | + | const match = /^g1t:\/\/([^?#]*)(\?[^#]*)?/i.exec(link); | |
| 61 | + | if (!match) return null; | |
| 62 | + | const path = `/${match[1].replace(/^\/+/, "")}`; | |
| 63 | + | return path + (match[2] ?? ""); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | function decodeURIComponentSafe(part: string): string { | |
| 67 | + | try { | |
| 68 | + | return decodeURIComponent(part); | |
| 69 | + | } catch { | |
| 70 | + | return part; | |
| 71 | + | } | |
| 72 | + | } |
| 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 | + | } |
| 1 | + | import { encodeOAuthClient } from "@g1t/contracts/oauth"; | |
| 2 | + | import Constants, { ExecutionEnvironment } from "expo-constants"; | |
| 3 | + | import * as AuthSession from "expo-auth-session"; | |
| 4 | + | import * as WebBrowser from "expo-web-browser"; | |
| 5 | + | import { Platform } from "react-native"; | |
| 6 | + | ||
| 7 | + | import type { Credentials } from "./credentials"; | |
| 8 | + | import { OAUTH_CALLBACK_PATH } from "./links"; | |
| 9 | + | import type { Server } from "./server"; | |
| 10 | + | ||
| 11 | + | /** | |
| 12 | + | * Signing in with g1t's OAuth 2.1, as any public client does | |
| 13 | + | * (docs.g1t.sh/guides/authentication/#signing-in-with-oauth): the | |
| 14 | + | * authorization code flow with PKCE (S256), no client secret, and a client | |
| 15 | + | * id that encodes the app's name and where it is sent back, so nothing is | |
| 16 | + | * registered first. | |
| 17 | + | * | |
| 18 | + | * The person approves on the site, in the system's browser, which keeps | |
| 19 | + | * their g1t session (and passkeys, password managers, two-factor) out of | |
| 20 | + | * the app. The site sends them back to `g1t://oauth/callback`. | |
| 21 | + | * | |
| 22 | + | * The app appears in Settings → Connected applications as "g1t for | |
| 23 | + | * Android" or "g1t for iOS", where its access can be narrowed or ended. | |
| 24 | + | */ | |
| 25 | + | ||
| 26 | + | // Lets the browser hand its result back when the app was in the background. | |
| 27 | + | WebBrowser.maybeCompleteAuthSession(); | |
| 28 | + | ||
| 29 | + | /** | |
| 30 | + | * What the app asks for: what Chat, Code and Customize show and do. The | |
| 31 | + | * person can untick any of these on the approval page. `chat:*` opens | |
| 32 | + | * Chat's routes and live sockets on the site, and `notifications:*` the | |
| 33 | + | * phone's registration for pushes (`/-/notify`), and nothing else there. | |
| 34 | + | */ | |
| 35 | + | export const SCOPES = [ | |
| 36 | + | "chat:read", | |
| 37 | + | "chat:write", | |
| 38 | + | "account:read", | |
| 39 | + | "workspace:read", | |
| 40 | + | "repo:read", | |
| 41 | + | "code:read", | |
| 42 | + | "issues:read", | |
| 43 | + | "issues:write", | |
| 44 | + | "pull_requests:read", | |
| 45 | + | "pull_requests:write", | |
| 46 | + | "checks:read", | |
| 47 | + | "workflows:read", | |
| 48 | + | "notifications:read", | |
| 49 | + | "notifications:write", | |
| 50 | + | "agents:run", | |
| 51 | + | "memory:read", | |
| 52 | + | "artifacts:read", | |
| 53 | + | ]; | |
| 54 | + | ||
| 55 | + | /** Whether the app is running inside Expo Go, while developing. */ | |
| 56 | + | export const IN_EXPO_GO = Constants.executionEnvironment === ExecutionEnvironment.StoreClient; | |
| 57 | + | ||
| 58 | + | /** The name shown on the approval page and in Connected applications. */ | |
| 59 | + | const CLIENT_NAME = IN_EXPO_GO ? "g1t in Expo Go" : Platform.OS === "ios" ? "g1t for iOS" : "g1t for Android"; | |
| 60 | + | ||
| 61 | + | /** | |
| 62 | + | * Where the site sends the person back: `g1t://oauth/callback` in a build | |
| 63 | + | * of the app, and Expo Go's own address while developing in it | |
| 64 | + | * (`exp://192.168.1.20:8081/--/oauth/callback`), which g1t accepts as an | |
| 65 | + | * application's own scheme. Since the client id encodes it, Go's id | |
| 66 | + | * changes with the computer's address; credentials keep the id they were | |
| 67 | + | * issued to, for refreshing. | |
| 68 | + | */ | |
| 69 | + | export function redirectUri(): string { | |
| 70 | + | return AuthSession.makeRedirectUri({ scheme: "g1t", path: OAUTH_CALLBACK_PATH.slice(1) }); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | export function clientId(redirect: string): string { | |
| 74 | + | const id = encodeOAuthClient({ name: CLIENT_NAME, redirectUris: [redirect] }); | |
| 75 | + | if (!id) throw new Error(`g1t does not accept ${redirect} as a place to send you back.`); | |
| 76 | + | return id; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | type Metadata = { | |
| 80 | + | issuer: string; | |
| 81 | + | authorization_endpoint: string; | |
| 82 | + | token_endpoint: string; | |
| 83 | + | revocation_endpoint?: string; | |
| 84 | + | }; | |
| 85 | + | ||
| 86 | + | /** The server's OAuth metadata (RFC 8414), from its API. */ | |
| 87 | + | async function metadata(server: Server): Promise<Metadata> { | |
| 88 | + | const response = await fetch(`${server.api}/.well-known/oauth-authorization-server`, { | |
| 89 | + | headers: { accept: "application/json" }, | |
| 90 | + | }); | |
| 91 | + | if (!response.ok) throw new Error(`${server.api} did not say how to sign in (${response.status}).`); | |
| 92 | + | return (await response.json()) as Metadata; | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | function discovery(meta: Metadata): AuthSession.DiscoveryDocument { | |
| 96 | + | return { | |
| 97 | + | authorizationEndpoint: meta.authorization_endpoint, | |
| 98 | + | tokenEndpoint: meta.token_endpoint, | |
| 99 | + | revocationEndpoint: meta.revocation_endpoint, | |
| 100 | + | }; | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | export class SignInCancelled extends Error { | |
| 104 | + | constructor() { | |
| 105 | + | super("Sign-in was cancelled."); | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /** | |
| 110 | + | * Opens the site's approval page and, once the person approves, trades the | |
| 111 | + | * code for tokens. Throws {@link SignInCancelled} when they close the page | |
| 112 | + | * or deny. | |
| 113 | + | */ | |
| 114 | + | export async function signInWithOAuth(server: Server): Promise<Credentials> { | |
| 115 | + | const meta = await metadata(server); | |
| 116 | + | const redirect = redirectUri(); | |
| 117 | + | const id = clientId(redirect); | |
| 118 | + | const request = new AuthSession.AuthRequest({ | |
| 119 | + | clientId: id, | |
| 120 | + | redirectUri: redirect, | |
| 121 | + | scopes: SCOPES, | |
| 122 | + | usePKCE: true, | |
| 123 | + | codeChallengeMethod: AuthSession.CodeChallengeMethod.S256, | |
| 124 | + | }); | |
| 125 | + | const result = await request.promptAsync(discovery(meta)); | |
| 126 | + | if (result.type === "cancel" || result.type === "dismiss") throw new SignInCancelled(); | |
| 127 | + | if (result.type !== "success") { | |
| 128 | + | const denied = result.type === "error" && result.params.error === "access_denied"; | |
| 129 | + | if (denied) throw new SignInCancelled(); | |
| 130 | + | throw new Error(result.type === "error" ? (result.params.error_description ?? result.params.error ?? "Sign-in failed.") : "Sign-in failed."); | |
| 131 | + | } | |
| 132 | + | // RFC 9207: the code must come from the server the app asked. | |
| 133 | + | if (result.params.iss && result.params.iss.replace(/\/$/, "") !== meta.issuer.replace(/\/$/, "")) { | |
| 134 | + | throw new Error("The sign-in came back from a different server than the one asked. Try again."); | |
| 135 | + | } | |
| 136 | + | const tokens = await AuthSession.exchangeCodeAsync( | |
| 137 | + | { | |
| 138 | + | clientId: id, | |
| 139 | + | code: result.params.code, | |
| 140 | + | redirectUri: redirect, | |
| 141 | + | extraParams: { code_verifier: request.codeVerifier ?? "" }, | |
| 142 | + | }, | |
| 143 | + | discovery(meta), | |
| 144 | + | ); | |
| 145 | + | return fromTokenResponse(tokens, id); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | function fromTokenResponse(tokens: AuthSession.TokenResponse, clientId: string): Credentials { | |
| 149 | + | if (!tokens.refreshToken) throw new Error("g1t did not return a refresh token."); | |
| 150 | + | return { | |
| 151 | + | kind: "oauth", | |
| 152 | + | accessToken: tokens.accessToken, | |
| 153 | + | refreshToken: tokens.refreshToken, | |
| 154 | + | expiresAt: (tokens.issuedAt + (tokens.expiresIn ?? 30 * 24 * 3600)) * 1000, | |
| 155 | + | scope: tokens.scope ?? "", | |
| 156 | + | clientId, | |
| 157 | + | }; | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /** Raised when the refresh token no longer works: the person must sign in again. */ | |
| 161 | + | export class SignedOut extends Error { | |
| 162 | + | constructor(message = "You were signed out. Sign in again.") { | |
| 163 | + | super(message); | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /** | |
| 168 | + | * The next pair of tokens. A refresh token works once: the old access | |
| 169 | + | * token stops working, so callers share one refresh at a time | |
| 170 | + | * (lib/client.ts). | |
| 171 | + | */ | |
| 172 | + | export async function refresh(server: Server, credentials: Extract<Credentials, { kind: "oauth" }>): Promise<Credentials> { | |
| 173 | + | const meta = await metadata(server); | |
| 174 | + | const id = credentials.clientId ?? clientId(redirectUri()); | |
| 175 | + | try { | |
| 176 | + | const tokens = await AuthSession.refreshAsync({ clientId: id, refreshToken: credentials.refreshToken }, discovery(meta)); | |
| 177 | + | return fromTokenResponse(tokens, id); | |
| 178 | + | } catch (error) { | |
| 179 | + | // invalid_grant: used, expired or revoked in Connected applications. | |
| 180 | + | if (error instanceof AuthSession.TokenError) throw new SignedOut(); | |
| 181 | + | throw error; | |
| 182 | + | } | |
| 183 | + | } |
| 1 | + | import Constants from "expo-constants"; | |
| 2 | + | import * as Device from "expo-device"; | |
| 3 | + | import * as Notifications from "expo-notifications"; | |
| 4 | + | import * as SecureStore from "expo-secure-store"; | |
| 5 | + | import { Platform } from "react-native"; | |
| 6 | + | ||
| 7 | + | import type { Client } from "./client"; | |
| 8 | + | import { IN_EXPO_GO } from "./oauth"; | |
| 9 | + | import { viewing } from "./viewing"; | |
| 10 | + | ||
| 11 | + | /** | |
| 12 | + | * Notifications on the phone. g1t's notify service sends them through | |
| 13 | + | * Expo's push service (FCM on Android, APNs on iOS) under the same rules | |
| 14 | + | * as browser push: what the person chose, never in Do Not Disturb, never | |
| 15 | + | * for a muted conversation, and not while a g1t tab or this app is in | |
| 16 | + | * front of them. The app registers its push token with the site's | |
| 17 | + | * `/-/notify` (`register_device`) once allowed, and takes it back on | |
| 18 | + | * signing out (`unregister_device`). | |
| 19 | + | * | |
| 20 | + | * Each push carries in `data` where it goes (`href`, a site path), its | |
| 21 | + | * `workspace`, and the `channel_id` and `thread_root` it is about. | |
| 22 | + | */ | |
| 23 | + | ||
| 24 | + | /** Android's channel for messages: what people turn on or off in the system's settings. */ | |
| 25 | + | export const MESSAGES_CHANNEL = "messages"; | |
| 26 | + | ||
| 27 | + | const TOKEN_KEY = "g1t.push-token"; | |
| 28 | + | ||
| 29 | + | export type PushData = { | |
| 30 | + | href?: string; | |
| 31 | + | workspace?: string; | |
| 32 | + | kind?: string; | |
| 33 | + | channel_id?: string | null; | |
| 34 | + | thread_root?: string | null; | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | // A push that arrives while the app is open shows, unless it is about | |
| 38 | + | // the conversation on screen. | |
| 39 | + | Notifications.setNotificationHandler({ | |
| 40 | + | handleNotification: async (notification) => { | |
| 41 | + | const data = (notification.request.content.data ?? {}) as PushData; | |
| 42 | + | const here = !!data.channel_id && data.channel_id === viewing.channelId; | |
| 43 | + | return { shouldShowBanner: !here, shouldShowList: !here, shouldPlaySound: !here, shouldSetBadge: true }; | |
| 44 | + | }, | |
| 45 | + | }); | |
| 46 | + | ||
| 47 | + | export type PushState = "unsupported" | "denied" | "undetermined" | "granted"; | |
| 48 | + | ||
| 49 | + | /** Whether pushes can work here: a real phone, in a build of the app (Expo Go on Android has no push). */ | |
| 50 | + | export function pushSupported(): boolean { | |
| 51 | + | return Device.isDevice && !(IN_EXPO_GO && Platform.OS === "android"); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | export async function pushState(): Promise<PushState> { | |
| 55 | + | if (!pushSupported()) return "unsupported"; | |
| 56 | + | const { status } = await Notifications.getPermissionsAsync(); | |
| 57 | + | return status === "granted" ? "granted" : status === "denied" ? "denied" : "undetermined"; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /** | |
| 61 | + | * Asks to notify (once; after a no, the system's settings decide), and | |
| 62 | + | * registers the phone with g1t. Safe to call on every launch: the token is | |
| 63 | + | * registered again, which keeps it fresh. | |
| 64 | + | */ | |
| 65 | + | export async function registerForPush(client: Client, ask: boolean): Promise<PushState> { | |
| 66 | + | if (!pushSupported()) return "unsupported"; | |
| 67 | + | if (Platform.OS === "android") { | |
| 68 | + | await Notifications.setNotificationChannelAsync(MESSAGES_CHANNEL, { | |
| 69 | + | name: "Messages", | |
| 70 | + | description: "Messages to you, mentions, replies, and agents waiting on you.", | |
| 71 | + | importance: Notifications.AndroidImportance.HIGH, | |
| 72 | + | vibrationPattern: [0, 120, 80, 120], | |
| 73 | + | lightColor: "#b6a8ff", | |
| 74 | + | showBadge: true, | |
| 75 | + | }); | |
| 76 | + | } | |
| 77 | + | let { status } = await Notifications.getPermissionsAsync(); | |
| 78 | + | if (status !== "granted" && ask) status = (await Notifications.requestPermissionsAsync()).status; | |
| 79 | + | if (status !== "granted") return status === "denied" ? "denied" : "undetermined"; | |
| 80 | + | ||
| 81 | + | const projectId = (Constants.expoConfig?.extra?.eas as { projectId?: string } | undefined)?.projectId ?? Constants.easConfig?.projectId; | |
| 82 | + | const { data: token } = await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined); | |
| 83 | + | await client.siteRaw("/-/notify", { | |
| 84 | + | method: "POST", | |
| 85 | + | body: JSON.stringify({ | |
| 86 | + | intent: "register_device", | |
| 87 | + | device: { token, platform: Platform.OS === "ios" ? "ios" : "android", name: Device.deviceName ?? Device.modelName ?? null }, | |
| 88 | + | }), | |
| 89 | + | }); | |
| 90 | + | await SecureStore.setItemAsync(TOKEN_KEY, token); | |
| 91 | + | return "granted"; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /** Takes the phone off the person's notifications, on signing out. */ | |
| 95 | + | export async function unregisterPush(client: Client): Promise<void> { | |
| 96 | + | const token = await SecureStore.getItemAsync(TOKEN_KEY).catch(() => null); | |
| 97 | + | if (!token) return; | |
| 98 | + | await client | |
| 99 | + | .siteRaw("/-/notify", { method: "POST", body: JSON.stringify({ intent: "unregister_device", token }) }) | |
| 100 | + | .catch(() => undefined); | |
| 101 | + | await SecureStore.deleteItemAsync(TOKEN_KEY).catch(() => undefined); | |
| 102 | + | await Notifications.setBadgeCountAsync(0).catch(() => undefined); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /** | |
| 106 | + | * Sends a test notification to this person's phones and browsers | |
| 107 | + | * (`POST /-/notify` `test`), whatever is in front of them: the way to see | |
| 108 | + | * that a build gets pushes. Answers how many it reached. | |
| 109 | + | */ | |
| 110 | + | export async function sendTestPush(client: Client): Promise<number> { | |
| 111 | + | const answer = await client.siteRaw<{ ok: boolean; pushed: number }>("/-/notify", { method: "POST", body: JSON.stringify({ intent: "test" }) }); | |
| 112 | + | return answer.pushed ?? 0; | |
| 113 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { HOSTED, apiFor, parseOrigin, parseServer, socketUrl } from "./server.ts"; | |
| 5 | + | ||
| 6 | + | test("an origin is all that is kept", () => { | |
| 7 | + | assert.equal(parseOrigin("https://g1t.sh/acme/-/chat?x#y"), "https://g1t.sh"); | |
| 8 | + | assert.equal(parseOrigin(" g1t.example.com "), "https://g1t.example.com"); | |
| 9 | + | assert.equal(parseOrigin("http://192.168.1.20:5173/login"), "http://192.168.1.20:5173"); | |
| 10 | + | assert.equal(parseOrigin("ftp://g1t.sh"), null); | |
| 11 | + | assert.equal(parseOrigin("not a url at all"), null); | |
| 12 | + | assert.equal(parseOrigin(""), null); | |
| 13 | + | }); | |
| 14 | + | ||
| 15 | + | test("hosted g1t's API is api.g1t.sh, and a self-hosted one's is api. on its host", () => { | |
| 16 | + | assert.equal(apiFor("https://g1t.sh"), "https://api.g1t.sh"); | |
| 17 | + | assert.equal(apiFor("https://g1t.example.com"), "https://api.g1t.example.com"); | |
| 18 | + | assert.deepEqual(parseServer("g1t.sh"), HOSTED); | |
| 19 | + | }); | |
| 20 | + | ||
| 21 | + | test("an API given by hand wins", () => { | |
| 22 | + | assert.deepEqual(parseServer("http://localhost:5173", "http://localhost:8789/"), { | |
| 23 | + | site: "http://localhost:5173", | |
| 24 | + | api: "http://localhost:8789", | |
| 25 | + | }); | |
| 26 | + | assert.equal(parseServer("g1t.sh", "javascript:alert(1)"), null); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("sockets follow the site's scheme", () => { | |
| 30 | + | assert.equal(socketUrl(HOSTED, "/acme/-/chat/live?channel=chn_1"), "wss://g1t.sh/acme/-/chat/live?channel=chn_1"); | |
| 31 | + | assert.equal(socketUrl({ site: "http://localhost:5173", api: "" }, "/-/live"), "ws://localhost:5173/-/live"); | |
| 32 | + | }); |
| 1 | + | /** | |
| 2 | + | * Which g1t the app talks to. Hosted g1t is the site `https://g1t.sh` and | |
| 3 | + | * the API `https://api.g1t.sh`; a self-hosted g1t sets its own (SITE_URL | |
| 4 | + | * and API_URL, apps/api/src/addresses.rs), so the app keeps both. | |
| 5 | + | * | |
| 6 | + | * The site is where people sign in and approve the app, and where Chat's | |
| 7 | + | * routes are; the API is the REST API and the OAuth issuer. | |
| 8 | + | * | |
| 9 | + | * Kept free of React Native imports so `npm test` runs it under Node. | |
| 10 | + | */ | |
| 11 | + | ||
| 12 | + | export type Server = { | |
| 13 | + | /** An origin, no trailing slash: `https://g1t.sh`. */ | |
| 14 | + | site: string; | |
| 15 | + | /** An origin, no trailing slash: `https://api.g1t.sh`. */ | |
| 16 | + | api: string; | |
| 17 | + | }; | |
| 18 | + | ||
| 19 | + | export const HOSTED: Server = { site: "https://g1t.sh", api: "https://api.g1t.sh" }; | |
| 20 | + | ||
| 21 | + | /** | |
| 22 | + | * An origin as typed: http or https with a host, `https://` when no scheme | |
| 23 | + | * is given; only the origin is kept. Null when it is not a web address. | |
| 24 | + | * Plain http is accepted for a g1t on this network, as in development. | |
| 25 | + | */ | |
| 26 | + | export function parseOrigin(text: string): string | null { | |
| 27 | + | const trimmed = text.trim(); | |
| 28 | + | if (!trimmed) return null; | |
| 29 | + | const withScheme = trimmed.includes("://") ? trimmed : `https://${trimmed}`; | |
| 30 | + | let url: URL; | |
| 31 | + | try { | |
| 32 | + | url = new URL(withScheme); | |
| 33 | + | } catch { | |
| 34 | + | return null; | |
| 35 | + | } | |
| 36 | + | if (url.protocol !== "https:" && url.protocol !== "http:") return null; | |
| 37 | + | if (!url.hostname) return null; | |
| 38 | + | return `${url.protocol}//${url.host}`; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /** | |
| 42 | + | * The API's address for a site, when only the site was given: hosted | |
| 43 | + | * g1t's own, or `api.` in front of a self-hosted site's host, which is | |
| 44 | + | * how deploy/self-host names it. | |
| 45 | + | */ | |
| 46 | + | export function apiFor(site: string): string { | |
| 47 | + | if (site === HOSTED.site) return HOSTED.api; | |
| 48 | + | const url = new URL(site); | |
| 49 | + | return `${url.protocol}//api.${url.host}`; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /** A server from what was typed: the site, and the API if it was given. */ | |
| 53 | + | export function parseServer(siteText: string, apiText?: string): Server | null { | |
| 54 | + | const site = parseOrigin(siteText); | |
| 55 | + | if (!site) return null; | |
| 56 | + | if (apiText && apiText.trim()) { | |
| 57 | + | const api = parseOrigin(apiText); | |
| 58 | + | if (!api) return null; | |
| 59 | + | return { site, api }; | |
| 60 | + | } | |
| 61 | + | return { site, api: apiFor(site) }; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | export function isHosted(server: Server): boolean { | |
| 65 | + | return server.site === HOSTED.site && server.api === HOSTED.api; | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /** The site's address for a path, such as a page to open in the browser. */ | |
| 69 | + | export function siteUrl(server: Server, path: string): string { | |
| 70 | + | return server.site + (path.startsWith("/") ? path : `/${path}`); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /** The socket address for one of the site's live paths. */ | |
| 74 | + | export function socketUrl(server: Server, path: string): string { | |
| 75 | + | return siteUrl(server, path).replace(/^http/, "ws"); | |
| 76 | + | } |
| 1 | + | import type { Membership, User } from "@g1t/contracts"; | |
| 2 | + | import { useQueryClient } from "@tanstack/react-query"; | |
| 3 | + | import * as SecureStore from "expo-secure-store"; | |
| 4 | + | import { type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; | |
| 5 | + | ||
| 6 | + | import { ApiError, Client } from "./client"; | |
| 7 | + | import { type Credentials, clearCredentials, loadCredentials, loadServer, saveCredentials, saveServer } from "./credentials"; | |
| 8 | + | import { signInWithOAuth } from "./oauth"; | |
| 9 | + | import { unregisterPush } from "./push"; | |
| 10 | + | import type { Server } from "./server"; | |
| 11 | + | ||
| 12 | + | /** | |
| 13 | + | * Who is signed in, to which g1t, and in which workspace. Everything that | |
| 14 | + | * talks to g1t reads its {@link Client} from here. | |
| 15 | + | */ | |
| 16 | + | ||
| 17 | + | /** `GET /user`: the person, their workspaces, and what the token may do. */ | |
| 18 | + | export type Me = User & { token?: { token_id: string; scopes?: string[] } }; | |
| 19 | + | ||
| 20 | + | type State = | |
| 21 | + | | { status: "loading"; server: Server | null } | |
| 22 | + | | { status: "signed-out"; server: Server } | |
| 23 | + | | { status: "signed-in"; server: Server; client: Client; me: Me; workspace: Membership | null }; | |
| 24 | + | ||
| 25 | + | type SignedInState = Extract<State, { status: "signed-in" }>; | |
| 26 | + | ||
| 27 | + | type SessionApi = { | |
| 28 | + | state: State; | |
| 29 | + | /** | |
| 30 | + | * The last signed-in state, kept through signing out: the signed-in | |
| 31 | + | * screens render once more as they leave, and read it then. | |
| 32 | + | */ | |
| 33 | + | lastSignedIn: SignedInState | null; | |
| 34 | + | /** Opens the site to approve the app, then signs in. */ | |
| 35 | + | signIn: () => Promise<void>; | |
| 36 | + | /** Signs in with a personal access token (`g1t_…`). */ | |
| 37 | + | signInWithToken: (token: string) => Promise<void>; | |
| 38 | + | signOut: () => Promise<void>; | |
| 39 | + | /** Points the app at another g1t; signs out of the one before. */ | |
| 40 | + | changeServer: (server: Server) => Promise<void>; | |
| 41 | + | /** Switches workspace by slug. */ | |
| 42 | + | selectWorkspace: (slug: string) => Promise<void>; | |
| 43 | + | }; | |
| 44 | + | ||
| 45 | + | const WORKSPACE_KEY = "g1t.workspace"; | |
| 46 | + | ||
| 47 | + | const SessionContext = createContext<SessionApi | null>(null); | |
| 48 | + | ||
| 49 | + | export function SessionProvider({ children }: { children: ReactNode }) { | |
| 50 | + | const [state, setState] = useState<State>({ status: "loading", server: null }); | |
| 51 | + | const queries = useQueryClient(); | |
| 52 | + | const serverRef = useRef<Server | null>(null); | |
| 53 | + | const lastSignedIn = useRef<SignedInState | null>(null); | |
| 54 | + | if (state.status === "signed-in") lastSignedIn.current = state; | |
| 55 | + | ||
| 56 | + | const signedOut = useCallback( | |
| 57 | + | async (server: Server) => { | |
| 58 | + | await clearCredentials(); | |
| 59 | + | queries.clear(); | |
| 60 | + | setState({ status: "signed-out", server }); | |
| 61 | + | }, | |
| 62 | + | [queries], | |
| 63 | + | ); | |
| 64 | + | ||
| 65 | + | /** Makes a client for credentials, reads who they are, and signs in. */ | |
| 66 | + | const start = useCallback( | |
| 67 | + | async (server: Server, credentials: Credentials) => { | |
| 68 | + | const client = new Client({ | |
| 69 | + | server, | |
| 70 | + | credentials, | |
| 71 | + | onCredentials: (next) => { | |
| 72 | + | if (next === null) void signedOut(server); | |
| 73 | + | }, | |
| 74 | + | }); | |
| 75 | + | const me = await client.api<Me>("/user"); | |
| 76 | + | if ((me.kind ?? "user") !== "user") { | |
| 77 | + | throw new ApiError("forbidden", "Sign in as yourself: a workspace's or an agent's token cannot use the app."); | |
| 78 | + | } | |
| 79 | + | const saved = await SecureStore.getItemAsync(WORKSPACE_KEY).catch(() => null); | |
| 80 | + | const workspaces = me.workspaces ?? []; | |
| 81 | + | const workspace = workspaces.find((one) => one.slug === saved) ?? workspaces[0] ?? null; | |
| 82 | + | setState({ status: "signed-in", server, client, me, workspace }); | |
| 83 | + | }, | |
| 84 | + | [signedOut], | |
| 85 | + | ); | |
| 86 | + | ||
| 87 | + | useEffect(() => { | |
| 88 | + | void (async () => { | |
| 89 | + | const server = await loadServer(); | |
| 90 | + | serverRef.current = server; | |
| 91 | + | const credentials = await loadCredentials(); | |
| 92 | + | if (!credentials) return setState({ status: "signed-out", server }); | |
| 93 | + | try { | |
| 94 | + | await start(server, credentials); | |
| 95 | + | } catch (error) { | |
| 96 | + | // Offline at launch keeps the credentials for next time; anything | |
| 97 | + | // else (revoked, expired) signs out. | |
| 98 | + | if (error instanceof ApiError && error.code === "offline") { | |
| 99 | + | setState({ status: "signed-out", server }); | |
| 100 | + | return; | |
| 101 | + | } | |
| 102 | + | await signedOut(server); | |
| 103 | + | } | |
| 104 | + | })(); | |
| 105 | + | }, [start, signedOut]); | |
| 106 | + | ||
| 107 | + | const api = useMemo<SessionApi>(() => { | |
| 108 | + | const current = () => serverRef.current ?? state.server; | |
| 109 | + | return { | |
| 110 | + | state, | |
| 111 | + | lastSignedIn: lastSignedIn.current, | |
| 112 | + | signIn: async () => { | |
| 113 | + | const server = current(); | |
| 114 | + | if (!server) return; | |
| 115 | + | const credentials = await signInWithOAuth(server); | |
| 116 | + | await saveCredentials(credentials); | |
| 117 | + | await start(server, credentials); | |
| 118 | + | }, | |
| 119 | + | signInWithToken: async (token) => { | |
| 120 | + | const server = current(); | |
| 121 | + | if (!server) return; | |
| 122 | + | const trimmed = token.trim(); | |
| 123 | + | if (!trimmed.startsWith("g1t_")) throw new ApiError("failed", "An access token starts with g1t_."); | |
| 124 | + | const credentials: Credentials = { kind: "token", accessToken: trimmed }; | |
| 125 | + | await start(server, credentials); | |
| 126 | + | await saveCredentials(credentials); | |
| 127 | + | }, | |
| 128 | + | signOut: async () => { | |
| 129 | + | const server = current(); | |
| 130 | + | // The phone stops getting notifications first, while the token still works. | |
| 131 | + | if (state.status === "signed-in") await unregisterPush(state.client).catch(() => undefined); | |
| 132 | + | if (server) await signedOut(server); | |
| 133 | + | }, | |
| 134 | + | changeServer: async (server) => { | |
| 135 | + | await saveServer(server); | |
| 136 | + | serverRef.current = server; | |
| 137 | + | await signedOut(server); | |
| 138 | + | }, | |
| 139 | + | selectWorkspace: async (slug) => { | |
| 140 | + | if (state.status !== "signed-in") return; | |
| 141 | + | const workspace = state.me.workspaces?.find((one) => one.slug.toLowerCase() === slug.toLowerCase()); | |
| 142 | + | if (!workspace || workspace.slug === state.workspace?.slug) return; | |
| 143 | + | await SecureStore.setItemAsync(WORKSPACE_KEY, workspace.slug); | |
| 144 | + | setState({ ...state, workspace }); | |
| 145 | + | }, | |
| 146 | + | }; | |
| 147 | + | }, [state, start, signedOut]); | |
| 148 | + | ||
| 149 | + | return <SessionContext.Provider value={api}>{children}</SessionContext.Provider>; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | export function useSession(): SessionApi { | |
| 153 | + | const session = useContext(SessionContext); | |
| 154 | + | if (!session) throw new Error("useSession is used outside SessionProvider."); | |
| 155 | + | return session; | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** The signed-in session, for screens that only show when signed in. */ | |
| 159 | + | export function useSignedIn() { | |
| 160 | + | const { state, lastSignedIn, ...rest } = useSession(); | |
| 161 | + | const signedIn = state.status === "signed-in" ? state : lastSignedIn; | |
| 162 | + | if (!signedIn) throw new Error("This screen needs a signed-in session."); | |
| 163 | + | return { ...signedIn, ...rest }; | |
| 164 | + | } |
| 1 | + | /** "now", "5m", "3h", "2d", or a date, for lists. */ | |
| 2 | + | export function ago(iso: string, now = Date.now()): string { | |
| 3 | + | const then = Date.parse(iso); | |
| 4 | + | if (Number.isNaN(then)) return ""; | |
| 5 | + | const seconds = Math.max(0, Math.round((now - then) / 1000)); | |
| 6 | + | if (seconds < 60) return "now"; | |
| 7 | + | const minutes = Math.round(seconds / 60); | |
| 8 | + | if (minutes < 60) return `${minutes}m`; | |
| 9 | + | const hours = Math.round(minutes / 60); | |
| 10 | + | if (hours < 24) return `${hours}h`; | |
| 11 | + | const days = Math.round(hours / 24); | |
| 12 | + | if (days < 7) return `${days}d`; | |
| 13 | + | return new Date(then).toLocaleDateString(undefined, { month: "short", day: "numeric" }); | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /** A message's time of day: "14:05". */ | |
| 17 | + | export function clock(iso: string): string { | |
| 18 | + | const date = new Date(iso); | |
| 19 | + | return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); | |
| 20 | + | } |
| 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 }; |
| 1 | + | import { useColorScheme } from "react-native"; | |
| 2 | + | ||
| 3 | + | import { dark, light, type Palette } from "./tokens"; | |
| 4 | + | ||
| 5 | + | export { fonts, radius, space, type Palette } from "./tokens"; | |
| 6 | + | ||
| 7 | + | /** The palette for the phone's light or dark setting; dark when it has none. */ | |
| 8 | + | export function useColors(): Palette { | |
| 9 | + | return useColorScheme() === "light" ? light : dark; | |
| 10 | + | } |
| 1 | + | /** | |
| 2 | + | * g1t's colours, as packages/theme/tokens.css names them, for React | |
| 3 | + | * Native, which cannot read CSS variables. Keep the two in step: a colour | |
| 4 | + | * changed there changes here. | |
| 5 | + | * | |
| 6 | + | * Dark is g1t's default, as on the site; light follows the phone's setting. | |
| 7 | + | */ | |
| 8 | + | ||
| 9 | + | export type Palette = { | |
| 10 | + | bg: string; | |
| 11 | + | surface: string; | |
| 12 | + | raised: string; | |
| 13 | + | line: string; | |
| 14 | + | lineStrong: string; | |
| 15 | + | fg: string; | |
| 16 | + | fgSoft: string; | |
| 17 | + | muted: string; | |
| 18 | + | faint: string; | |
| 19 | + | accent: string; | |
| 20 | + | accentDim: string; | |
| 21 | + | accentLow: string; | |
| 22 | + | /** Text on an accent-filled button. */ | |
| 23 | + | onAccent: string; | |
| 24 | + | /** The rail and the bottom bar (apps/web app.css `--color-rail`). */ | |
| 25 | + | rail: string; | |
| 26 | + | /** What marks the app you are in on the rail (`--color-sidebar-accent`). */ | |
| 27 | + | railAccent: string; | |
| 28 | + | success: string; | |
| 29 | + | successLow: string; | |
| 30 | + | info: string; | |
| 31 | + | merged: string; | |
| 32 | + | warn: string; | |
| 33 | + | danger: string; | |
| 34 | + | }; | |
| 35 | + | ||
| 36 | + | export const dark: Palette = { | |
| 37 | + | bg: "#0f0f11", | |
| 38 | + | surface: "#161618", | |
| 39 | + | raised: "#1e1e21", | |
| 40 | + | line: "#28282c", | |
| 41 | + | lineStrong: "#38383e", | |
| 42 | + | fg: "#ededef", | |
| 43 | + | fgSoft: "#dcdce0", | |
| 44 | + | muted: "#a0a0a8", | |
| 45 | + | faint: "#86868e", | |
| 46 | + | accent: "#b6a8ff", | |
| 47 | + | accentDim: "#8577e0", | |
| 48 | + | accentLow: "#221e38", | |
| 49 | + | onAccent: "#0f0f11", | |
| 50 | + | rail: "#050507", | |
| 51 | + | railAccent: "#1c1c20", | |
| 52 | + | success: "#86efc4", | |
| 53 | + | successLow: "#10302a", | |
| 54 | + | info: "#8ab4ff", | |
| 55 | + | merged: "#a678f5", | |
| 56 | + | warn: "#ffbd8c", | |
| 57 | + | danger: "#ff8394", | |
| 58 | + | }; | |
| 59 | + | ||
| 60 | + | export const light: Palette = { | |
| 61 | + | bg: "#fbfbfa", | |
| 62 | + | surface: "#ffffff", | |
| 63 | + | raised: "#f1f1ef", | |
| 64 | + | line: "#e4e4e1", | |
| 65 | + | lineStrong: "#d2d2ce", | |
| 66 | + | fg: "#18181b", | |
| 67 | + | fgSoft: "#2e2e33", | |
| 68 | + | muted: "#5c5c66", | |
| 69 | + | faint: "#686871", | |
| 70 | + | accent: "#6b52c8", | |
| 71 | + | accentDim: "#9584dc", | |
| 72 | + | accentLow: "#ece8fb", | |
| 73 | + | onAccent: "#ffffff", | |
| 74 | + | rail: "#ffffff", | |
| 75 | + | railAccent: "#e1e1de", | |
| 76 | + | success: "#0d7a52", | |
| 77 | + | successLow: "#e2f4ec", | |
| 78 | + | info: "#2160cc", | |
| 79 | + | merged: "#7c3fd1", | |
| 80 | + | warn: "#a84f00", | |
| 81 | + | danger: "#c42a43", | |
| 82 | + | }; | |
| 83 | + | ||
| 84 | + | export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 } as const; | |
| 85 | + | ||
| 86 | + | export const radius = { sm: 6, md: 10, lg: 14, full: 999 } as const; | |
| 87 | + | ||
| 88 | + | /** | |
| 89 | + | * The site's faces (Hanken Grotesk, Bricolage Grotesque, IBM Plex Mono) | |
| 90 | + | * ship as woff2, which phones cannot load; until TTF copies are bundled | |
| 91 | + | * with expo-font, the system's faces stand in. | |
| 92 | + | */ | |
| 93 | + | export const fonts = { mono: "monospace" } as const; |
| 1 | + | { | |
| 2 | + | "extends": "expo/tsconfig.base", | |
| 3 | + | "compilerOptions": { | |
| 4 | + | "strict": true, | |
| 5 | + | "skipLibCheck": true, | |
| 6 | + | "paths": { | |
| 7 | + | "@/*": ["./src/*"], | |
| 8 | + | "@/assets/*": ["./assets/*"] | |
| 9 | + | } | |
| 10 | + | }, | |
| 11 | + | "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"], | |
| 12 | + | // Node runs the tests on the files as they are (`npm test`). | |
| 13 | + | "exclude": ["node_modules", "src/**/*.test.ts"] | |
| 14 | + | } |
| 353 | 353 | ||
| 354 | 354 | /* | |
| 355 | 355 | * The app's frame (components/shell.tsx), set on <body data-frame="app">: | |
| 356 | − | * the page fills the window beside the rail and the sidebar; on a phone | |
| 357 | − | * it is a rounded panel inset above the bottom bar. What sticks to the top | |
| 358 | − | * or the bottom of a page reads these: | |
| 359 | − | * --frame-top the gap above the page (none on a computer) | |
| 356 | + | * the page fills the window beside the rail and the sidebar, and on a | |
| 357 | + | * phone it fills the window above the bottom bar, flat, as on a computer. | |
| 358 | + | * What sticks to the top or the bottom of a page reads these: | |
| 359 | + | * --frame-top what is above the page: a phone's status bar, where it overlaps | |
| 360 | 360 | * --topbar-h that gap plus the top bar: where sticky things sit | |
| 361 | − | * --tabbar-h what the bottom takes: on a phone, the gap under the panel and the bar | |
| 361 | + | * --tabbar-h what the bottom takes: on a phone, the bar and the home indicator under it | |
| 362 | 362 | * --page-h the height left for a page that fills the window (Chat) | |
| 363 | 363 | * The public frame keeps a plain header 3.5rem tall and nothing at the bottom. | |
| 364 | 364 | */ | |
| ⋯ | |||
| 374 | 374 | } | |
| 375 | 375 | @media (max-width: 767px) { | |
| 376 | 376 | body[data-frame="app"] { | |
| 377 | − | --frame-top: max(0.375rem, env(safe-area-inset-top)); | |
| 378 | − | /* The bottom bar: 64px tall, 8px off the edge, 6px under the panel. */ | |
| 379 | − | --tabbar-h: calc(4.875rem + env(safe-area-inset-bottom)); | |
| 377 | + | --frame-top: env(safe-area-inset-top); | |
| 378 | + | /* The bottom bar: 56px tall along the bottom edge, and the home indicator's room under it. */ | |
| 379 | + | --tabbar-h: calc(3.5rem + env(safe-area-inset-bottom)); | |
| 380 | 380 | } | |
| 381 | 381 | } | |
| 382 | 382 | ||
| 68 | 68 | import { type Mentionable, type ShownMessage, agentsAmong, channelPath, dmPlaceholder, isMixed, mentionNames, mergeMessages, mixedNotice, shownHandle, shownName, timeline } from "../../lib/chat"; | |
| 69 | 69 | import { codeAccessPath } from "../../lib/workspace-nav"; | |
| 70 | 70 | import { conversationCache } from "./conversation-cache"; | |
| 71 | + | import { atBottom, useKeepAtBottom } from "./stick"; | |
| 71 | 72 | // Reactions and the workspace's own emoji (components/emoji). | |
| 72 | 73 | import { EmojiProvider, useCustomEmoji } from "../emoji/context"; | |
| 73 | 74 | import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar"; | |
| ⋯ | |||
| 243 | 244 | useEffect(() => { | |
| 244 | 245 | if (stick.current) toBottom(true); | |
| 245 | 246 | }, [messages.length, typing.size, toBottom]); | |
| 247 | + | // And while the list or what is in it changes size: the keyboard opening on a phone, a card laying out. | |
| 248 | + | useKeepAtBottom(scroller, stick); | |
| 246 | 249 | ||
| 247 | 250 | // Read up to the newest message whenever it is in view. | |
| 248 | 251 | const lastRead = useRef<string | null>(null); | |
| ⋯ | |||
| 648 | 651 | ref={scroller} | |
| 649 | 652 | onScroll={(event) => { | |
| 650 | 653 | const element = event.currentTarget; | |
| 651 | − | stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 120; | |
| 654 | + | stick.current = atBottom(element); | |
| 652 | 655 | }} | |
| 653 | 656 | className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]" | |
| 654 | 657 | > | |
| ⋯ | |||
| 1579 | 1582 | draftKey: string; | |
| 1580 | 1583 | }) { | |
| 1581 | 1584 | const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]); | |
| 1585 | + | // The newest reply stays in view as replies arrive and as the keyboard opens, unless the reader scrolled up. | |
| 1586 | + | const scroller = useRef<HTMLDivElement>(null); | |
| 1587 | + | const stick = useRef(true); | |
| 1588 | + | useKeepAtBottom(scroller, stick); | |
| 1582 | 1589 | return ( | |
| 1583 | 1590 | <> | |
| 1584 | − | <div className="min-h-0 grow overflow-y-auto px-1 py-3 [scrollbar-width:thin]"> | |
| 1591 | + | <div | |
| 1592 | + | ref={scroller} | |
| 1593 | + | onScroll={(event) => { | |
| 1594 | + | stick.current = atBottom(event.currentTarget); | |
| 1595 | + | }} | |
| 1596 | + | className="min-h-0 grow overflow-y-auto overscroll-contain px-1 py-3 [scrollbar-width:thin]" | |
| 1597 | + | > | |
| 1598 | + | {/* One box for what scrolls, so its growing is seen (./stick.ts). */} | |
| 1599 | + | <div> | |
| 1585 | 1600 | {root ? ( | |
| 1586 | 1601 | <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread /> | |
| 1587 | 1602 | ) : ( | |
| ⋯ | |||
| 1606 | 1621 | /> | |
| 1607 | 1622 | ) : null, | |
| 1608 | 1623 | )} | |
| 1624 | + | </div> | |
| 1609 | 1625 | </div> | |
| 1610 | 1626 | {joined && ( | |
| 1611 | 1627 | <div className="shrink-0 p-3"> | |
| 1 | + | import { type RefObject, useEffect, useRef } from "react"; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * Keeps a conversation's newest message in view while the reader is at | |
| 5 | + | * the bottom: when the list itself changes size (the keyboard opening on | |
| 6 | + | * a phone shrinks it, a window resized) and when what is in it does (a | |
| 7 | + | * card, an image or a code block laying out after it arrived). Without | |
| 8 | + | * this, a list that gets shorter keeps its distance from the top, and the | |
| 9 | + | * newest messages slide out of sight under the composer. | |
| 10 | + | * | |
| 11 | + | * `stick` says whether the reader is at the bottom (within 120px), as the | |
| 12 | + | * list's `onScroll` keeps it (`atBottom`); a reader scrolled up into the | |
| 13 | + | * history is left where they are. | |
| 14 | + | */ | |
| 15 | + | export function useKeepAtBottom(scroller: RefObject<HTMLElement | null>, stick: RefObject<boolean>) { | |
| 16 | + | useEffect(() => { | |
| 17 | + | const element = scroller.current; | |
| 18 | + | if (!element || typeof ResizeObserver === "undefined") return; | |
| 19 | + | const keep = () => { | |
| 20 | + | if (stick.current) element.scrollTop = element.scrollHeight; | |
| 21 | + | }; | |
| 22 | + | const observer = new ResizeObserver(keep); | |
| 23 | + | observer.observe(element); | |
| 24 | + | for (const child of Array.from(element.children)) observer.observe(child); | |
| 25 | + | return () => observer.disconnect(); | |
| 26 | + | }, [scroller, stick]); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** Whether a list is scrolled to (within 120px of) its bottom. */ | |
| 30 | + | export function atBottom(element: HTMLElement): boolean { | |
| 31 | + | return element.scrollHeight - element.scrollTop - element.clientHeight < 120; | |
| 32 | + | } |
| 120 | 120 | type Tab = { key: ModeKey; label: string; badge?: { count: number; loud: boolean } }; | |
| 121 | 121 | ||
| 122 | 122 | /** | |
| 123 | − | * The bottom bar of a phone, floating 8px from the edges: Home, Chat, | |
| 123 | + | * The bottom bar of a phone, flat along the bottom edge under a rule, as | |
| 124 | + | * the rail stands flat beside the page on a computer: Home, Chat, | |
| 124 | 125 | * Notifications, Agents and Code (for a member with Code access), each | |
| 125 | − | * with what is unread, and More. It steps aside while the keyboard is up | |
| 126 | + | * with what is unread, and More. The tab you are in has the rail's own | |
| 127 | + | * mark: its icon on the sidebar's accent. It steps aside while the keyboard is up | |
| 126 | 128 | * and inside a conversation. The tab you are in, tapped again, opens its | |
| 127 | 129 | * mode's menu (its sidebar). | |
| 128 | 130 | */ | |
| ⋯ | |||
| 160 | 162 | <> | |
| 161 | 163 | <nav | |
| 162 | 164 | aria-label="Apps" | |
| 163 | − | className="fixed right-2 bottom-[calc(0.5rem+env(safe-area-inset-bottom))] left-2 z-40 h-16 rounded-2xl border border-line bg-rail px-1 shadow-[0_1px_0_rgba(255,255,255,0.03)_inset,0_8px_24px_rgba(0,0,0,0.35)] md:hidden in-data-[keyboard=open]:hidden" | |
| 165 | + | className="fixed inset-x-0 bottom-0 z-40 h-(--tabbar-h) border-t border-line bg-rail pb-[env(safe-area-inset-bottom)] md:hidden in-data-[keyboard=open]:hidden" | |
| 164 | 166 | > | |
| 165 | 167 | <ul className="grid h-full" style={{ gridTemplateColumns: `repeat(${tabs.length + 1}, minmax(0, 1fr))` }}> | |
| 166 | 168 | {tabs.map((tab) => { | |
| ⋯ | |||
| 183 | 185 | }} | |
| 184 | 186 | className={BAR_ITEM} | |
| 185 | 187 | > | |
| 186 | − | {current && <TopBar />} | |
| 187 | 188 | <BarIcon current={current}> | |
| 188 | 189 | {appIcon(tab.key as BuiltinApp, 20)} | |
| 189 | 190 | {tab.badge && <CountBadge count={count} loud={tab.badge.loud} />} | |
| 190 | 191 | </BarIcon> | |
| 191 | − | <span className={`w-full truncate text-[0.625rem] leading-tight font-medium max-[380px]:text-[0.5625rem] max-[380px]:tracking-[-0.02em] ${current ? "text-fg" : "text-faint"}`}>{tab.label}</span> | |
| 192 | + | <BarLabel current={current}>{tab.label}</BarLabel> | |
| 192 | 193 | </Link> | |
| 193 | 194 | </li> | |
| 194 | 195 | ); | |
| 195 | 196 | })} | |
| 196 | 197 | <li className="min-w-0"> | |
| 197 | 198 | <button type="button" aria-expanded={more} aria-haspopup="dialog" onClick={() => setMore(true)} className={BAR_ITEM}> | |
| 198 | − | {(more || !inTabs) && <TopBar />} | |
| 199 | 199 | <BarIcon current={more || !inTabs}> | |
| 200 | 200 | <Ellipsis size={20} /> | |
| 201 | 201 | </BarIcon> | |
| 202 | − | <span className={`w-full truncate text-[0.625rem] leading-tight font-medium ${more || !inTabs ? "text-fg" : "text-faint"}`}>More</span> | |
| 202 | + | <BarLabel current={more || !inTabs}>More</BarLabel> | |
| 203 | 203 | </button> | |
| 204 | 204 | </li> | |
| 205 | 205 | </ul> | |
| ⋯ | |||
| 209 | 209 | ); | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | − | const BAR_ITEM = "relative flex h-full w-full flex-col items-center justify-center gap-0.5 rounded-xl outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent active:bg-raised/60"; | |
| 212 | + | const BAR_ITEM = "group relative flex h-full w-full flex-col items-center justify-center gap-1 outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset"; | |
| 213 | 213 | ||
| 214 | − | /** The bar on the bottom bar's top edge, over the current tab. */ | |
| 215 | − | function TopBar() { | |
| 216 | − | return <span aria-hidden="true" className="absolute -top-px left-1/2 h-[3px] w-[22px] -translate-x-1/2 rounded-b-[3px] bg-fg" />; | |
| 214 | + | /** A tab's icon, marked as the rail marks the app you are in (ui/sidebar.tsx, `size: icon`). */ | |
| 215 | + | function BarIcon({ current, children }: { current: boolean; children: ReactNode }) { | |
| 216 | + | return ( | |
| 217 | + | <span | |
| 218 | + | className={`relative flex h-7 w-11 items-center justify-center rounded-[10px] transition-colors group-active:bg-sidebar-accent/60 ${current ? "bg-sidebar-accent text-fg" : "text-muted"}`} | |
| 219 | + | > | |
| 220 | + | {children} | |
| 221 | + | </span> | |
| 222 | + | ); | |
| 217 | 223 | } | |
| 218 | 224 | ||
| 219 | − | function BarIcon({ current, children }: { current: boolean; children: ReactNode }) { | |
| 225 | + | /** A tab's name, centred under its icon; a long one gives way rather than crowding its neighbours. */ | |
| 226 | + | function BarLabel({ current, children }: { current: boolean; children: ReactNode }) { | |
| 220 | 227 | return ( | |
| 221 | − | <span className={`relative flex size-8 items-center justify-center rounded-[10px] transition-colors ${current ? "bg-raised text-fg shadow-[inset_0_0_0_1px_var(--color-line-strong)]" : "text-muted"}`}> | |
| 228 | + | <span className={`block w-full truncate px-0.5 text-center text-[0.6875rem] leading-none font-medium max-[380px]:text-[0.625rem] ${current ? "text-fg" : "text-faint"}`}> | |
| 222 | 229 | {children} | |
| 223 | 230 | </span> | |
| 224 | 231 | ); | |
| ⋯ | |||
| 228 | 235 | function MoreTile({ to, icon, label, onClick, disabled, note }: { to?: string; icon: ReactNode; label: string; onClick?: () => void; disabled?: boolean; note?: string }) { | |
| 229 | 236 | const inner = ( | |
| 230 | 237 | <> | |
| 231 | − | <span className={`flex size-11 items-center justify-center rounded-xl ${disabled ? "bg-raised/60 text-faint" : "bg-raised text-fg"}`}>{icon}</span> | |
| 238 | + | <span className={`flex size-11 items-center justify-center rounded-[10px] ${disabled ? "bg-raised/60 text-faint" : "bg-raised text-fg"}`}>{icon}</span> | |
| 232 | 239 | <span className={`line-clamp-2 w-full text-xs leading-tight max-[380px]:text-[0.6875rem] ${disabled ? "text-faint" : "text-fg-soft"}`}>{label}</span> | |
| 233 | 240 | {note && <span className="-mt-0.5 rounded-full bg-line px-1.5 text-[0.625rem] font-medium text-muted">{note}</span>} | |
| 234 | 241 | </> | |
| 235 | 242 | ); | |
| 236 | − | const className = "flex min-w-0 flex-col items-center gap-1.5 rounded-xl px-1 py-2.5 text-center outline-none focus-visible:ring-2 focus-visible:ring-accent active:bg-raised/60"; | |
| 243 | + | const className = "flex min-w-0 flex-col items-center gap-1.5 rounded-md px-1 py-2.5 text-center outline-none focus-visible:ring-2 focus-visible:ring-accent active:bg-raised/60"; | |
| 237 | 244 | if (to) { | |
| 238 | 245 | return ( | |
| 239 | 246 | <Link to={to} className={className}> | |
| ⋯ | |||
| 249 | 256 | } | |
| 250 | 257 | ||
| 251 | 258 | /** | |
| 252 | − | * More, from the bottom bar: a rounded panel just above it, with g1t's | |
| 259 | + | * More, from the bottom bar: a flat panel standing on it, with g1t's | |
| 253 | 260 | * mark at its top and a grid of big tiles: all apps, the Marketplace, | |
| 254 | 261 | * your pinned apps, Artifacts, People, Workspace, and "You and | |
| 255 | 262 | * help", which opens your account's sheet. | |
| ⋯ | |||
| 266 | 273 | <Primitive.Content | |
| 267 | 274 | aria-describedby={undefined} | |
| 268 | 275 | onOpenAutoFocus={(event) => event.preventDefault()} | |
| 269 | − | className="fixed right-2 bottom-[calc(4.875rem+env(safe-area-inset-bottom))] left-2 z-50 max-h-[calc(100dvh-7rem)] overflow-y-auto rounded-[18px] border border-line-strong bg-rail px-3 pt-2 pb-3.5 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out md:hidden" | |
| 276 | + | className="fixed inset-x-0 bottom-(--tabbar-h) z-50 max-h-[calc(100dvh-var(--tabbar-h)-3.5rem)] overflow-y-auto border-t border-line bg-rail px-3 pt-2 pb-3.5 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out md:hidden" | |
| 270 | 277 | > | |
| 271 | 278 | <div aria-hidden="true" className="mx-auto mt-0.5 mb-2 h-1 w-9 rounded-full bg-line-strong" /> | |
| 272 | 279 | <div className="mb-2 flex items-center gap-2 px-1"> | |
| ⋯ | |||
| 342 | 349 | aria-describedby={undefined} | |
| 343 | 350 | onOpenAutoFocus={(event) => event.preventDefault()} | |
| 344 | 351 | style={{ transform: drag ? `translateY(${drag}px)` : undefined, transition: from.current == null ? "transform 0.2s ease-out" : "none" }} | |
| 345 | − | className="fixed inset-x-0 bottom-0 z-50 flex max-h-[88dvh] flex-col rounded-t-2xl border-t border-line-strong bg-surface pb-[env(safe-area-inset-bottom)] text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-[sheet-in-bottom_0.24s_cubic-bezier(0.16,1,0.3,1)]" | |
| 352 | + | className="fixed inset-x-0 bottom-0 z-50 flex max-h-[88dvh] flex-col border-t border-line bg-surface pb-[env(safe-area-inset-bottom)] text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-[sheet-in-bottom_0.24s_cubic-bezier(0.16,1,0.3,1)]" | |
| 346 | 353 | > | |
| 347 | 354 | <div {...handlers} className="flex shrink-0 cursor-grab touch-none justify-center pt-2.5 pb-2" aria-hidden="true"> | |
| 348 | 355 | <span className="h-1.5 w-10 rounded-full bg-line-strong" /> | |
| ⋯ | |||
| 357 | 364 | ||
| 358 | 365 | /** A row in a bottom sheet: 48px tall, an icon, words, and what sits at its end. */ | |
| 359 | 366 | export function SheetRow({ to, href, icon, children, end, onClick }: { to?: string; href?: string; icon: ReactNode; children: ReactNode; end?: ReactNode; onClick?: () => void }) { | |
| 360 | − | const className = "flex h-12 w-full items-center gap-3 rounded-xl px-3 text-left text-[0.9375rem] text-fg-soft transition-colors active:bg-raised"; | |
| 367 | + | const className = "flex h-12 w-full items-center gap-3 rounded-md px-3 text-left text-[0.9375rem] text-fg-soft transition-colors active:bg-raised"; | |
| 361 | 368 | const inner = ( | |
| 362 | 369 | <> | |
| 363 | 370 | <span className="flex w-6 shrink-0 justify-center text-muted [&_svg]:size-5">{icon}</span> | |
| 288 | 288 | children, | |
| 289 | 289 | }: { | |
| 290 | 290 | title: string; | |
| 291 | − | /** A small button at the label's end: what the group makes (a `SidebarGroupAction`). */ | |
| 291 | + | /** A small button at the label's end, in its row: what the group makes (a `SidebarGroupAction` with `relative top-auto right-auto`). */ | |
| 292 | 292 | action?: ReactNode; | |
| 293 | 293 | className?: string; | |
| 294 | 294 | children: ReactNode; | |
| 295 | 295 | }) { | |
| 296 | 296 | return ( | |
| 297 | 297 | <SidebarGroup className={className}> | |
| 298 | − | <SidebarGroupLabel asChild> | |
| 299 | − | <h2>{title}</h2> | |
| 300 | − | </SidebarGroupLabel> | |
| 301 | − | {action} | |
| 298 | + | <div className="flex items-center justify-between gap-2 pr-1"> | |
| 299 | + | <SidebarGroupLabel asChild> | |
| 300 | + | <h2>{title}</h2> | |
| 301 | + | </SidebarGroupLabel> | |
| 302 | + | {action} | |
| 303 | + | </div> | |
| 302 | 304 | <SidebarGroupContent> | |
| 303 | 305 | <SidebarMenu>{children}</SidebarMenu> | |
| 304 | 306 | </SidebarGroupContent> | |
| ⋯ | |||
| 688 | 690 | ); | |
| 689 | 691 | return ( | |
| 690 | 692 | <SidebarGroup> | |
| 691 | − | <SidebarGroupLabel asChild> | |
| 692 | − | <h2>Projects</h2> | |
| 693 | − | </SidebarGroupLabel> | |
| 694 | − | <Hint label="New project"> | |
| 695 | − | <SidebarGroupAction asChild> | |
| 696 | − | <Link to="/new" aria-label="New project"> | |
| 697 | − | <Plus /> | |
| 698 | − | </Link> | |
| 699 | − | </SidebarGroupAction> | |
| 700 | − | </Hint> | |
| 693 | + | {/* The label and its button share a row, so the button stays level with the words at any text size. */} | |
| 694 | + | <div className="flex items-center justify-between gap-2 pr-1"> | |
| 695 | + | <SidebarGroupLabel asChild> | |
| 696 | + | <h2>Projects</h2> | |
| 697 | + | </SidebarGroupLabel> | |
| 698 | + | <Hint label="New project"> | |
| 699 | + | <SidebarGroupAction asChild className="relative top-auto right-auto"> | |
| 700 | + | <Link to="/new" aria-label="New project"> | |
| 701 | + | <Plus /> | |
| 702 | + | </Link> | |
| 703 | + | </SidebarGroupAction> | |
| 704 | + | </Hint> | |
| 705 | + | </div> | |
| 701 | 706 | <SidebarGroupContent> | |
| 702 | 707 | {/* Only there once something is pinned: each row below has its own pin. */} | |
| 703 | 708 | {pinned.length > 0 && ( | |
| ⋯ | |||
| 1941 | 1946 | * filling the rest of the window, with its top bar across the top and a | |
| 1942 | 1947 | * line under it: the sidebar's toggle, where you are, search, Ask g1t and | |
| 1943 | 1948 | * Create new. On a phone the rail is a bar along the bottom and the page | |
| 1944 | − | * a rounded panel above it. Only for someone signed in; without a workspace yet, there | |
| 1949 | + | * fills the window above it, flat as well. Only for someone signed in; without a workspace yet, there | |
| 1945 | 1950 | * is no rail. The rail and the sidebar are drawn from the sidebar kit | |
| 1946 | 1951 | * (components/ui/sidebar.tsx): one provider holds whether the sidebar is | |
| 1947 | 1952 | * open on a computer and whether the drawer is. | |
| ⋯ | |||
| 2089 | 2094 | </SidebarMobile> | |
| 2090 | 2095 | )} | |
| 2091 | 2096 | ||
| 2092 | − | <div className={`min-h-dvh pt-(--frame-top) pb-(--tabbar-h) max-md:px-1.5 ${pad}`}> | |
| 2097 | + | <div className={`min-h-dvh pt-(--frame-top) pb-(--tabbar-h) ${pad}`}> | |
| 2093 | 2098 | {/* The top bar always has its line, so a page's own header (data-page-head: a conversation's, a project's, | |
| 2094 | 2099 | a sticky one under an open artifact) sits under it as its own band. */} | |
| 2095 | − | <div className="flex min-h-[calc(100dvh-var(--frame-top)-var(--tabbar-h))] min-w-0 flex-col bg-bg max-md:rounded-[14px]"> | |
| 2100 | + | <div className="flex min-h-[calc(100dvh-var(--frame-top)-var(--tabbar-h))] min-w-0 flex-col bg-bg"> | |
| 2096 | 2101 | <header | |
| 2097 | − | className={`sticky top-(--frame-top) z-30 flex h-14 items-center gap-1 border-b border-line bg-bg pr-2 pl-2 max-md:rounded-t-[14px] sm:pr-3 ${inline ? "lg:pl-3.5" : ""} ${conversation ? "max-md:hidden" : ""}`} | |
| 2102 | + | className={`sticky top-(--frame-top) z-30 flex h-14 items-center gap-1 border-b border-line bg-bg pr-2 pl-2 sm:pr-3 ${inline ? "lg:pl-3.5" : ""} ${conversation ? "max-md:hidden" : ""}`} | |
| 2098 | 2103 | > | |
| 2099 | 2104 | {panel && ( | |
| 2100 | 2105 | <SidebarTrigger | |
| ⋯ | |||
| 2142 | 2147 | </main> | |
| 2143 | 2148 | </div> | |
| 2144 | 2149 | </div> | |
| 2145 | − | {/* On a phone the page is a rounded panel above the bottom bar: a frame over it, with the background beyond, | |
| 2146 | − | so what scrolls stays inside. A phone's conversation, full screen over everything, goes without. */} | |
| 2147 | − | <div | |
| 2148 | − | aria-hidden="true" | |
| 2149 | − | className={`pointer-events-none fixed top-(--frame-top) right-1.5 bottom-(--tabbar-h) left-1.5 z-[35] rounded-[14px] shadow-[0_0_0_1px_var(--color-line),0_0_0_100vmax_var(--color-shell)] md:hidden ${conversation ? "max-md:hidden" : ""}`} | |
| 2150 | − | /> | |
| 2151 | 2150 | {ws && <BottomBar user={user} workspace={ws} pins={shell.pins ?? []} unread={unread} onReselect={panel ? () => setDrawer(true) : undefined} />} | |
| 2152 | 2151 | <CommandPalette open={palette} onOpenChange={setPalette} commands={commands} repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null} /> | |
| 2153 | 2152 | </SidebarProvider> | |
| 160 | 160 | event.preventDefault(); | |
| 161 | 161 | (event.currentTarget as HTMLElement | null)?.focus(); | |
| 162 | 162 | }} | |
| 163 | − | className={cn("group/sidebar w-[min(var(--sidebar-width),86vw)] border-sidebar-border bg-sidebar px-0 text-fg", className)} | |
| 163 | + | // A phone's drawer takes most of the screen: names read whole and rows reach a thumb, with the page still showing beside it. | |
| 164 | + | className={cn("group/sidebar w-[min(22rem,86vw)] border-sidebar-border bg-sidebar px-0 text-fg", className)} | |
| 164 | 165 | {...props} | |
| 165 | 166 | > | |
| 166 | 167 | <SheetTitle className="sr-only">{title}</SheetTitle> | |
| ⋯ | |||
| 235 | 236 | } | |
| 236 | 237 | ||
| 237 | 238 | export function SidebarSeparator({ className, ...props }: ComponentProps<typeof Separator>) { | |
| 238 | − | return <Separator data-slot="sidebar-separator" className={cn("mx-2 my-2 w-auto bg-sidebar-border", className)} {...props} />; | |
| 239 | + | return <Separator data-slot="sidebar-separator" className={cn("mx-2 my-2 bg-sidebar-border data-[orientation=horizontal]:w-auto", className)} {...props} />; | |
| 239 | 240 | } | |
| 240 | 241 | ||
| 241 | 242 | /** What scrolls: the groups, between the header and the footer. */ | |
| 5 | 5 | import { getViewer, roleIn } from "./session.server"; | |
| 6 | 6 | import { identity } from "./services.server"; | |
| 7 | 7 | import { issueTicket, socketPath, ticketViewer } from "./socket-ticket"; | |
| 8 | − | import { bearerToken, websiteUser } from "./website-token"; | |
| 8 | + | import { bearerToken, socketUser } from "./website-token"; | |
| 9 | 9 | ||
| 10 | 10 | let isolateSecret: string | null = null; | |
| 11 | 11 | ||
| ⋯ | |||
| 35 | 35 | export async function socketTicketFor(context: Parameters<typeof getViewer>[0], request: Request, path: string): Promise<{ ticket: string; expires_at: string } | null> { | |
| 36 | 36 | const viewer = getViewer(context); | |
| 37 | 37 | const token = bearerToken(request); | |
| 38 | − | if (!viewer || !token || !viewer.token?.website) return null; | |
| 38 | + | if (!viewer || !token || !viewer.token) return null; | |
| 39 | 39 | 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; | |
| 41 | 41 | return issueTicket(ticketSecret(), { token, userId: viewer.id, path: socket.path }); | |
| 42 | 42 | } | |
| 43 | 43 | ||
| ⋯ | |||
| 49 | 49 | export async function socketViewer(context: Parameters<typeof getViewer>[0], request: Request): Promise<User | null> { | |
| 50 | 50 | const viewer = getViewer(context); | |
| 51 | 51 | 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)); | |
| 53 | 53 | } | |
| 5 | 5 | ||
| 6 | 6 | import { liveAddress, offerTicket, openLive, setLiveViaToken, takeOfferedTicket } from "./live-socket.ts"; | |
| 7 | 7 | 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"; | |
| 9 | 9 | ||
| 10 | 10 | const SECRET = "site-secret"; | |
| 11 | 11 | const NOW = Date.UTC(2026, 9, 9, 12, 0, 0); | |
| ⋯ | |||
| 178 | 178 | globalThis.fetch = was.fetch; | |
| 179 | 179 | } | |
| 180 | 180 | }); | |
| 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 | + | }); | |
| 19 | 19 | * - holds the token itself, encrypted and authenticated (AES-GCM) under a | |
| 20 | 20 | * key only the site has, so the upgrade checks the token exactly as a | |
| 21 | 21 | * 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 | |
| 23 | 24 | * ticket's minute; | |
| 24 | 25 | * - is never stored, logged or passed on: the socket handlers build the | |
| 25 | 26 | * service's address afresh, without it. | |
| ⋯ | |||
| 165 | 166 | /** | |
| 166 | 167 | * The person a socket's ticket signs in, checked as a page request with | |
| 167 | 168 | * 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. | |
| 169 | 170 | * Null for anything but a WebSocket upgrade to the socket the ticket was | |
| 170 | 171 | * made for, and for a ticket that is not genuine, has expired, or whose | |
| 171 | 172 | * token no longer may use the website. | |
| ⋯ | |||
| 173 | 174 | export async function ticketViewer( | |
| 174 | 175 | request: Request, | |
| 175 | 176 | secret: string, | |
| 176 | − | lookup: (token: string) => Promise<User | null>, | |
| 177 | + | lookup: (token: string, path: string) => Promise<User | null>, | |
| 177 | 178 | nowMs = Date.now(), | |
| 178 | 179 | ): Promise<User | null> { | |
| 179 | 180 | if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return null; | |
| ⋯ | |||
| 184 | 185 | if (!socket) return null; | |
| 185 | 186 | const opened = await openTicket(secret, ticket, socket.path, nowMs); | |
| 186 | 187 | if (!opened || !opened.token.startsWith("g1t_")) return null; | |
| 187 | − | const user = await lookup(opened.token); | |
| 188 | + | const user = await lookup(opened.token, socket.path); | |
| 188 | 189 | return user && user.id === opened.userId ? user : null; | |
| 189 | 190 | } | |
| 11 | 11 | bearerToken, | |
| 12 | 12 | isNeedsSignIn, | |
| 13 | 13 | needsRealSignIn, | |
| 14 | + | scopedRoute, | |
| 15 | + | scopedUser, | |
| 16 | + | socketUser, | |
| 14 | 17 | tokenVerdict, | |
| 15 | 18 | websiteUser, | |
| 16 | 19 | } from "./website-token.ts"; | |
| ⋯ | |||
| 169 | 172 | assert.ok(!crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "https://g1t.sh" }))); | |
| 170 | 173 | assert.ok(!crossOrigin(post(bearer("g1t_web"))), "automation that sends no Origin is not another site"); | |
| 171 | 174 | }); | |
| 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 | + | }); | |
| 19 | 19 | * authentication, passwords, email addresses, SSH and signing keys, | |
| 20 | 20 | * applications, deleting the account or a workspace, giving a workspace | |
| 21 | 21 | * 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. | |
| 22 | 30 | * - A token that is not accepted is no one: a page loads signed out, and a | |
| 23 | 31 | * data request or form post is refused with a 401 that says why. | |
| 24 | 32 | */ | |
| 25 | 33 | ||
| 26 | 34 | import type { User, Viewer } from "@g1t/contracts"; | |
| 35 | + | import { type Scope, tokenAllows } from "@g1t/contracts/scopes"; | |
| 27 | 36 | ||
| 28 | 37 | /** | |
| 29 | 38 | * The page a request is for, as routes match it: decoded, any case, no | |
| ⋯ | |||
| 31 | 40 | * page (as lib/confirm-gate.ts's `pageOf`). | |
| 32 | 41 | */ | |
| 33 | 42 | 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); | |
| 41 | 44 | if (path.endsWith(".data")) { | |
| 42 | 45 | path = path.slice(0, -".data".length); | |
| 43 | 46 | if (path === "/_root") path = "/"; | |
| ⋯ | |||
| 46 | 49 | return path || "/"; | |
| 47 | 50 | } | |
| 48 | 51 | ||
| 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 | + | ||
| 49 | 63 | /** Where the docs explain it. */ | |
| 50 | 64 | export const WEBSITE_TOKEN_DOCS = "https://docs.g1t.sh/guides/authentication/#use-a-token-on-the-website"; | |
| 51 | 65 | ||
| ⋯ | |||
| 66 | 80 | * permission. Null otherwise. | |
| 67 | 81 | */ | |
| 68 | 82 | 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 { | |
| 69 | 93 | if (!viewer || (viewer.kind ?? "user") !== "user" || viewer.acting) return null; | |
| 70 | 94 | 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; | |
| 72 | 96 | return viewer; | |
| 73 | 97 | } | |
| 74 | 98 | ||
| 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 | + | ||
| 75 | 170 | /** Why a token was not accepted, for the 401. */ | |
| 76 | 171 | export const TOKEN_REFUSED = | |
| 77 | 172 | "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. " + | |
| ⋯ | |||
| 168 | 263 | if (token === null) return { kind: "none" }; | |
| 169 | 264 | const { pathname } = new URL(request.url); | |
| 170 | 265 | 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); | |
| 172 | 270 | if (!user) return wantsData(pathname, method) ? { kind: "refused", status: 401, body: TOKEN_REFUSED } : { kind: "signed-out" }; | |
| 173 | 271 | let form: { get(name: string): unknown } | null = null; | |
| 174 | 272 | if (readsForm(pathname, method)) { | |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import { data } from "react-router"; | |
| 3 | 3 | ||
| 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"; | |
| 5 | 5 | import type { SoundSettingsChange } from "@g1t/contracts/sounds"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/api"; | |
| ⋯ | |||
| 15 | 15 | ||
| 16 | 16 | /** | |
| 17 | 17 | * 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). | |
| 22 | 27 | */ | |
| 23 | 28 | export async function loader({ context, request }: Route.LoaderArgs) { | |
| 24 | 29 | const user = requireUser(context, request); | |
| ⋯ | |||
| 31 | 36 | intent?: string; | |
| 32 | 37 | subscription?: PushSubscriptionJson & { expirationTime?: number | null }; | |
| 33 | 38 | endpoint?: string; | |
| 39 | + | device?: PushDevice; | |
| 40 | + | token?: string; | |
| 34 | 41 | preferences?: NotifyPreferencesChange; | |
| 35 | 42 | change?: PresenceChange | SoundSettingsChange; | |
| 36 | 43 | }; | |
| ⋯ | |||
| 49 | 56 | } | |
| 50 | 57 | case "unsubscribe": | |
| 51 | 58 | 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 ?? ""))); | |
| 52 | 69 | case "preferences": | |
| 53 | 70 | return Response.json(await notify.setPreferences(user, sent.preferences ?? {})); | |
| 54 | 71 | case "test": | |
| 2 | 2 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 3 | 3 | import { issueTicket, socketPath } from "../../lib/socket-ticket"; | |
| 4 | 4 | import { ticketSecret } from "../../lib/socket-ticket.server"; | |
| 5 | − | import { bearerToken } from "../../lib/website-token"; | |
| 5 | + | import { bearerToken, socketUser } from "../../lib/website-token"; | |
| 6 | 6 | ||
| 7 | 7 | const PRIVATE = { "cache-control": "no-store", "x-robots-tag": "noindex" }; | |
| 8 | 8 | ||
| ⋯ | |||
| 11 | 11 | * `GET /-/live/ticket?path=/<workspace>/-/chat/live` answers | |
| 12 | 12 | * `{ ticket, expires_at }`, good for a minute on that socket alone | |
| 13 | 13 | * (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`). | |
| 15 | 17 | */ | |
| 16 | 18 | export async function loader({ context, request }: Route.LoaderArgs) { | |
| 17 | 19 | const viewer = getViewer(context); | |
| 18 | 20 | const token = bearerToken(request); | |
| 19 | − | if (!viewer || !token || !viewer.token?.website) { | |
| 21 | + | if (!viewer || !token || !viewer.token) { | |
| 20 | 22 | return Response.json( | |
| 21 | 23 | { message: "Socket tickets are for pages opened with an access token; a signed-in browser's sockets use its session." }, | |
| 22 | 24 | { status: viewer ? 400 : 401, headers: PRIVATE }, | |
| ⋯ | |||
| 24 | 26 | } | |
| 25 | 27 | const socket = socketPath(new URL(request.url).searchParams.get("path") ?? ""); | |
| 26 | 28 | 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 | + | } | |
| 27 | 32 | if (socket.workspace && !roleIn(viewer, socket.workspace)) { | |
| 28 | 33 | return Response.json({ message: "Not found" }, { status: 404, headers: PRIVATE }); | |
| 29 | 34 | } | |
| 10 | 10 | ||
| 11 | 11 | /** | |
| 12 | 12 | * 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 | |
| 14 | 15 | * sends: messages, read marks, stars, new channels and direct messages, | |
| 15 | 16 | * and the actions pressed on cards. | |
| 16 | 17 | * Every answer is `{ ok, value }` or `{ ok: false, error }`, the services' | |
| ⋯ | |||
| 25 | 26 | const answer = await safely<unknown>(() => { | |
| 26 | 27 | // The workspace's own emoji, for the picker and for drawing :name: (components/emoji). | |
| 27 | 28 | 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 }); | |
| 28 | 31 | if (!channel) return chat.sidebar(slug, viewer); | |
| 29 | 32 | return chat.messages(slug, channel, viewer, { | |
| 30 | 33 | thread_root: url.searchParams.get("thread"), | |
| 335 | 335 | ||
| 336 | 336 | Every access token and OAuth sign-in has scopes, `resource:level`: | |
| 337 | 337 | `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 | |
| 339 | 339 | as each has them), `agents:run`, `workspace:read` and `workspace:admin`. A higher | |
| 340 | 340 | level includes the lower. A token may expire. It reaches every workspace | |
| 341 | 341 | and repository its owner can (a workspace's token, that workspace only); | |
| ⋯ | |||
| 349 | 349 | token reaches one workspace (or only its owner's account), all, chosen or | |
| 350 | 350 | only public repositories of it, with permissions (`contents`, `issues`, | |
| 351 | 351 | `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`, | |
| 353 | 353 | `issues:write`, `pull_requests:write`, `agents:run`, `memory:write`, | |
| 354 | 354 | `notifications:write`). For CI, the CI preset (`repo:read`, `code:read`, | |
| 355 | 355 | `code:write`, `packages:read`, `packages:write`, `workflows:read`, | |
| 356 | 356 | `workflows:write`, `deployments:read`, `deployments:write`). `models:write` (sending requests through the AI | |
| 357 | 357 | 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. | |
| 358 | 362 | OAuth clients may send `scope`; | |
| 359 | 363 | the person can untick any; asking for none gives the Agent preset. Tokens | |
| 360 | 364 | from device sign-in (above) have full access. Guide: | |
| 24 | 24 | pub enum Resource { | |
| 25 | 25 | Account, | |
| 26 | 26 | 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, | |
| 27 | 31 | Workspace, | |
| 28 | 32 | Billing, | |
| 29 | 33 | Repo, | |
| ⋯ | |||
| 50 | 54 | } | |
| 51 | 55 | ||
| 52 | 56 | impl Resource { | |
| 53 | − | pub const ALL: [Resource; 22] = [ | |
| 57 | + | pub const ALL: [Resource; 23] = [ | |
| 54 | 58 | Resource::Repo, | |
| 55 | 59 | Resource::Code, | |
| 56 | 60 | Resource::Security, | |
| ⋯ | |||
| 65 | 69 | Resource::Memory, | |
| 66 | 70 | Resource::Account, | |
| 67 | 71 | Resource::Notifications, | |
| 72 | + | Resource::Chat, | |
| 68 | 73 | Resource::Workspace, | |
| 69 | 74 | Resource::Billing, | |
| 70 | 75 | Resource::Access, | |
| ⋯ | |||
| 79 | 84 | match self { | |
| 80 | 85 | Resource::Account => "account", | |
| 81 | 86 | Resource::Notifications => "notifications", | |
| 87 | + | Resource::Chat => "chat", | |
| 82 | 88 | Resource::Workspace => "workspace", | |
| 83 | 89 | Resource::Billing => "billing", | |
| 84 | 90 | Resource::Repo => "repo", | |
| ⋯ | |||
| 107 | 113 | match self { | |
| 108 | 114 | Resource::Account => "Your account", | |
| 109 | 115 | Resource::Notifications => "Notifications", | |
| 116 | + | Resource::Chat => "Chat", | |
| 110 | 117 | Resource::Workspace => "Workspaces", | |
| 111 | 118 | Resource::Billing => "Billing", | |
| 112 | 119 | Resource::Repo => "Repositories", | |
| ⋯ | |||
| 198 | 205 | AccountWrite, | |
| 199 | 206 | NotificationsRead, | |
| 200 | 207 | NotificationsWrite, | |
| 208 | + | ChatRead, | |
| 209 | + | ChatWrite, | |
| 201 | 210 | WorkspaceRead, | |
| 202 | 211 | WorkspaceAdmin, | |
| 203 | 212 | BillingRead, | |
| ⋯ | |||
| 219 | 228 | ||
| 220 | 229 | impl Scope { | |
| 221 | 230 | /// Every scope, grouped by resource, least first. | |
| 222 | − | pub const ALL: [Scope; 45] = [ | |
| 231 | + | pub const ALL: [Scope; 47] = [ | |
| 223 | 232 | Scope::RepoRead, | |
| 224 | 233 | Scope::RepoWrite, | |
| 225 | 234 | Scope::RepoAdmin, | |
| ⋯ | |||
| 248 | 257 | Scope::AccountWrite, | |
| 249 | 258 | Scope::NotificationsRead, | |
| 250 | 259 | Scope::NotificationsWrite, | |
| 260 | + | Scope::ChatRead, | |
| 261 | + | Scope::ChatWrite, | |
| 251 | 262 | Scope::WorkspaceRead, | |
| 252 | 263 | Scope::WorkspaceAdmin, | |
| 253 | 264 | Scope::BillingRead, | |
| ⋯ | |||
| 297 | 308 | Scope::AccountWrite => "account:write", | |
| 298 | 309 | Scope::NotificationsRead => "notifications:read", | |
| 299 | 310 | Scope::NotificationsWrite => "notifications:write", | |
| 311 | + | Scope::ChatRead => "chat:read", | |
| 312 | + | Scope::ChatWrite => "chat:write", | |
| 300 | 313 | Scope::WorkspaceRead => "workspace:read", | |
| 301 | 314 | Scope::WorkspaceAdmin => "workspace:admin", | |
| 302 | 315 | Scope::BillingRead => "billing:read", | |
| ⋯ | |||
| 383 | 396 | Scope::AccountWrite => "Change your email addresses, make invites, answer invitations, pin projects and star repositories", | |
| 384 | 397 | Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch", | |
| 385 | 398 | 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", | |
| 386 | 401 | Scope::WorkspaceRead => "Read workspace settings, invites, integrations, model routes, teams and rulesets", | |
| 387 | 402 | Scope::WorkspaceAdmin => "Create and delete workspaces, invite members, connect integrations, create, change and delete teams, and change the workspace's rulesets", | |
| 388 | 403 | Scope::BillingRead => "See a workspace's usage, budget, AI credit and invoices", | |
| ⋯ | |||
| 478 | 493 | impl Resource { | |
| 479 | 494 | pub fn group(self) -> ResourceGroup { | |
| 480 | 495 | match self { | |
| 481 | − | Resource::Account | Resource::Notifications => ResourceGroup::Account, | |
| 496 | + | Resource::Account | Resource::Notifications | Resource::Chat => ResourceGroup::Account, | |
| 482 | 497 | Resource::Workspace | Resource::Billing | Resource::Runners | Resource::Models | Resource::Artifacts => ResourceGroup::Workspace, | |
| 483 | 498 | _ => ResourceGroup::Repository, | |
| 484 | 499 | } | |
| ⋯ | |||
| 591 | 606 | ||
| 592 | 607 | /// Its scopes; `None` for full access. | |
| 593 | 608 | 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 | + | }; | |
| 595 | 616 | match self { | |
| 596 | 617 | Preset::ReadOnly => Some(reads().collect()), | |
| 597 | 618 | Preset::Agent => { | |
| ⋯ | |||
| 1489 | 1510 | assert!(scopes.contains(&Scope::AgentsRun)); | |
| 1490 | 1511 | assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}"); | |
| 1491 | 1512 | 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:?}"); | |
| 1494 | 1515 | } | |
| 1495 | 1516 | assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read)); | |
| 1496 | 1517 | assert_eq!(Preset::Full.scopes(), None); | |
| ⋯ | |||
| 1566 | 1587 | } | |
| 1567 | 1588 | ||
| 1568 | 1589 | #[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] | |
| 1569 | 1618 | fn checks_are_reported_with_checks_write_which_ci_gets() { | |
| 1570 | 1619 | assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite)); | |
| 1571 | 1620 | assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite)); | |
| 158 | 158 | "stage": "core", | |
| 159 | 159 | // The private half of the VAPID key pair browser pushes are signed | |
| 160 | 160 | // 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. | |
| 161 | 164 | "secrets": ["VAPID_PRIVATE_KEY"], | |
| 162 | 165 | "setup": [ | |
| 163 | 166 | "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" |
Binary or large file; its contents are not shown.
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "license": "MIT", | |
| 7 | − | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts" }, | |
| 7 | + | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts", "./oauth": "./src/oauth.ts", "./customize": "./src/customize.ts", "./skills": "./src/skills.ts" }, | |
| 8 | 8 | "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" } | |
| 9 | 9 | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { CUSTOMIZE, CUSTOMIZE_SECTIONS, customizePath, sectionOfListing } from "./customize.ts"; | |
| 5 | + | ||
| 6 | + | test("every Marketplace listing has a place in Customize", () => { | |
| 7 | + | assert.equal(sectionOfListing("integration"), "connectors"); | |
| 8 | + | assert.equal(sectionOfListing("extension"), "plugins"); | |
| 9 | + | for (const section of CUSTOMIZE_SECTIONS) assert.equal(CUSTOMIZE[section].id, section); | |
| 10 | + | }); | |
| 11 | + | ||
| 12 | + | test("a section's site path names its workspace", () => { | |
| 13 | + | assert.equal(customizePath("connectors", "acme"), "/acme/-/marketplace/integrations"); | |
| 14 | + | assert.equal(customizePath("skills", "a b"), "/a%20b/-/agents/skills"); | |
| 15 | + | }); |
| 1 | + | /** | |
| 2 | + | * Customize: one place for what shapes how a workspace's agents work, in | |
| 3 | + | * three parts. It is the Marketplace's successor, and what the phone app | |
| 4 | + | * shows today; the site moves to it in its own change. | |
| 5 | + | * | |
| 6 | + | * - **Skills**: what agents know how to do, in the open SKILL.md format | |
| 7 | + | * (./skill-library.ts): the foundational skills every agent has, and the | |
| 8 | + | * workspace's library. | |
| 9 | + | * - **Connectors**: what agents can reach: the tools a team already uses | |
| 10 | + | * (./connectors.ts). Today's Marketplace integrations. | |
| 11 | + | * - **Plugins**: what adds pages, cards, tools and agent roles to the | |
| 12 | + | * workspace (./marketplace.ts's `ExtensionManifest`). Today's | |
| 13 | + | * Marketplace extensions. | |
| 14 | + | * | |
| 15 | + | * A Marketplace listing (`integration:<id>`, `extension:<id>`) keeps its | |
| 16 | + | * reference; {@link sectionOfListing} says which part shows it. No imports | |
| 17 | + | * beyond types, so it runs anywhere: services, the site, the phone. | |
| 18 | + | */ | |
| 19 | + | ||
| 20 | + | import type { ListingKind } from "./marketplace"; | |
| 21 | + | ||
| 22 | + | export type CustomizeSection = "skills" | "connectors" | "plugins"; | |
| 23 | + | ||
| 24 | + | export const CUSTOMIZE_SECTIONS: readonly CustomizeSection[] = ["skills", "connectors", "plugins"]; | |
| 25 | + | ||
| 26 | + | export type CustomizeSectionInfo = { | |
| 27 | + | id: CustomizeSection; | |
| 28 | + | title: string; | |
| 29 | + | /** One line, for the section's row. */ | |
| 30 | + | description: string; | |
| 31 | + | /** Where the site manages it today, with `:workspace` to fill in. */ | |
| 32 | + | sitePath: string; | |
| 33 | + | }; | |
| 34 | + | ||
| 35 | + | export const CUSTOMIZE: Record<CustomizeSection, CustomizeSectionInfo> = { | |
| 36 | + | skills: { | |
| 37 | + | id: "skills", | |
| 38 | + | title: "Skills", | |
| 39 | + | description: "What agents know how to do: the skills every agent has, and your workspace's library.", | |
| 40 | + | sitePath: "/:workspace/-/agents/skills", | |
| 41 | + | }, | |
| 42 | + | connectors: { | |
| 43 | + | id: "connectors", | |
| 44 | + | title: "Connectors", | |
| 45 | + | description: "The tools your team already uses, connected so agents can work with their data.", | |
| 46 | + | sitePath: "/:workspace/-/marketplace/integrations", | |
| 47 | + | }, | |
| 48 | + | plugins: { | |
| 49 | + | id: "plugins", | |
| 50 | + | title: "Plugins", | |
| 51 | + | description: "Pages, cards, tools and agent roles for what your team does besides code.", | |
| 52 | + | sitePath: "/:workspace/-/marketplace/extensions", | |
| 53 | + | }, | |
| 54 | + | }; | |
| 55 | + | ||
| 56 | + | /** The part of Customize that shows a Marketplace listing of this kind. */ | |
| 57 | + | export function sectionOfListing(kind: ListingKind): CustomizeSection { | |
| 58 | + | return kind === "integration" ? "connectors" : "plugins"; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /** A section's site path with its workspace filled in. */ | |
| 62 | + | export function customizePath(section: CustomizeSection, workspace: string): string { | |
| 63 | + | return CUSTOMIZE[section].sitePath.replace(/:workspace\b/g, encodeURIComponent(workspace)); | |
| 64 | + | } |
| 15 | 15 | export * from "./compute"; | |
| 16 | 16 | export * from "./connectors"; | |
| 17 | 17 | export * from "./context"; | |
| 18 | + | export * from "./customize"; | |
| 18 | 19 | export * from "./d1"; | |
| 19 | 20 | export * from "./datasets"; | |
| 20 | 21 | export * from "./docs"; |
| 1 | 1 | /** | |
| 2 | 2 | * Notifications: the notify service (`services/notify`) keeps one feed per | |
| 3 | 3 | * 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. | |
| 5 | 6 | * | |
| 6 | 7 | * Chat tells it about every message a person should count or hear of, the | |
| 7 | 8 | * events service about every inbox item, and the site forwards each tab's | |
| ⋯ | |||
| 91 | 92 | keys: { p256dh: string; auth: string }; | |
| 92 | 93 | }; | |
| 93 | 94 | ||
| 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 | + | ||
| 94 | 102 | /** A conversation's unread counts for one person. */ | |
| 95 | 103 | export type ChannelCounts = { channel_id: string; unread: number; mentions: number; muted: boolean }; | |
| 96 | 104 | ||
| ⋯ | |||
| 232 | 240 | preferences: NotifyPreferences; | |
| 233 | 241 | /** How many browsers get pushes. */ | |
| 234 | 242 | subscriptions: number; | |
| 243 | + | /** How many phones the g1t app pushes to (absent from a service older than them). */ | |
| 244 | + | devices?: number; | |
| 235 | 245 | /** Whether this browser's endpoint is one of them, when it was asked about. */ | |
| 236 | 246 | subscribed: boolean; | |
| 237 | 247 | /** The public key a browser subscribes with; null when push is not set up. */ | |
| ⋯ | |||
| 245 | 255 | deliver(items: FeedDelivery[]): Promise<{ ok: boolean }>; | |
| 246 | 256 | subscribe(user: User, subscription: PushSubscriptionJson, userAgent?: string | null): Promise<{ ok: boolean }>; | |
| 247 | 257 | 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 }>; | |
| 248 | 262 | status(user: User, endpoint?: string | null): Promise<NotifyStatus>; | |
| 249 | 263 | setPreferences(user: User, preferences: NotifyPreferencesChange): Promise<NotifyPreferences>; | |
| 250 | 264 | /** Sends the person a test notification, toasted and pushed whatever their focus. */ | |
| ⋯ | |||
| 293 | 307 | deliver: (items) => call("deliver", { items }), | |
| 294 | 308 | subscribe: (user, subscription, userAgent) => call("subscribe", { user_id: user.id, subscription, user_agent: userAgent ?? null }), | |
| 295 | 309 | 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 }), | |
| 296 | 312 | status: (user, endpoint) => call("status", { user_id: user.id, endpoint: endpoint ?? null }), | |
| 297 | 313 | setPreferences: (user, preferences) => call("set_preferences", { user_id: user.id, preferences }), | |
| 298 | 314 | test: (user) => call("test", { user_id: user.id, username: user.username }), | |
| 24 | 24 | | "memory" | |
| 25 | 25 | | "account" | |
| 26 | 26 | | "notifications" | |
| 27 | + | | "chat" | |
| 27 | 28 | | "workspace" | |
| 28 | 29 | | "billing" | |
| 29 | 30 | | "access" | |
| ⋯ | |||
| 65 | 66 | { scope: "account:write", description: "Change your email addresses, make invites, answer invitations, pin projects and star repositories" }, | |
| 66 | 67 | { scope: "notifications:read", description: "See your inbox, its threads, and what you subscribe to and watch" }, | |
| 67 | 68 | { 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" }, | |
| 68 | 71 | { scope: "workspace:read", description: "Read workspace settings, invites, integrations, model routes, teams and rulesets" }, | |
| 69 | 72 | { scope: "workspace:admin", description: "Create and delete workspaces, invite members, connect integrations, create, change and delete teams, and change the workspace's rulesets" }, | |
| 70 | 73 | { scope: "billing:read", description: "See a workspace's usage, budget, AI credit and invoices" }, | |
| ⋯ | |||
| 115 | 118 | { resource: "memory", label: "Memory and context", group: "repository" }, | |
| 116 | 119 | { resource: "account", label: "Your account", group: "account" }, | |
| 117 | 120 | { resource: "notifications", label: "Notifications", group: "account" }, | |
| 121 | + | { resource: "chat", label: "Chat", group: "account" }, | |
| 118 | 122 | { resource: "workspace", label: "Workspaces", group: "workspace" }, | |
| 119 | 123 | { resource: "billing", label: "Billing", group: "workspace" }, | |
| 120 | 124 | { resource: "access", label: "Who has access", group: "repository" }, | |
| ⋯ | |||
| 161 | 165 | ); | |
| 162 | 166 | } | |
| 163 | 167 | ||
| 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 | + | ||
| 164 | 178 | /** The levels a resource has, least first. */ | |
| 165 | 179 | export function levelsOf(resource: ScopeResource): ScopeLevel[] { | |
| 166 | 180 | return SCOPES.filter((row) => scopeResource(row.scope) === resource).map((row) => scopeLevel(row.scope)); | |
| ⋯ | |||
| 215 | 229 | ||
| 216 | 230 | export type PresetId = "read_only" | "agent" | "ci" | "full"; | |
| 217 | 231 | ||
| 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. */ | |
| 219 | 233 | export const PRESET_SCOPES = { | |
| 220 | 234 | read_only: [ | |
| 221 | 235 | "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", | |
| ⋯ | |||
| 654 | 668 | { id: "memory", label: "Memory & search", scopes: ["memory:read", "memory:write"] }, | |
| 655 | 669 | { id: "account", label: "Account", scopes: ["account:read", "account:write"] }, | |
| 656 | 670 | { id: "notifications", label: "Notifications", scopes: ["notifications:read", "notifications:write"] }, | |
| 671 | + | { id: "chat", label: "Chat", scopes: ["chat:read", "chat:write"] }, | |
| 657 | 672 | { id: "workspace", label: "Workspace", scopes: ["workspace:read", "access:read", "webhooks:read", "secrets:read"] }, | |
| 658 | 673 | { id: "billing", label: "Billing", scopes: ["billing:read", "billing:write"] }, | |
| 659 | 674 | { id: "runners", label: "Runners", scopes: ["runners:read"] }, | |
| 1264 | 1264 | } | |
| 1265 | 1265 | ||
| 1266 | 1266 | /// 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. | |
| 1269 | 1269 | #[test] | |
| 1270 | 1270 | fn the_migration_sets_full_access_out_as_every_permission() { | |
| 1271 | 1271 | use g1t_contracts::scopes::{Resource, ResourceGroup, everything}; | |
| 1272 | 1272 | 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(); | |
| 1274 | 1277 | assert!(sql.contains(&format!("SET scopes = '{}'", scopes_text(&then)))); | |
| 1275 | 1278 | let workspace: Vec<Scope> = then.into_iter().filter(|scope| scope.resource().group() != ResourceGroup::Account).collect(); | |
| 1276 | 1279 | assert!(sql.contains(&format!("SET scopes = '{}'", scopes_text(&workspace)))); |
| 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 | + | }); |
| 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 | + | } |
| 9 | 9 | * focus and page (`serializeAttachment`), which survive hibernation. | |
| 10 | 10 | * | |
| 11 | 11 | * 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 | |
| 13 | 15 | * the person's status, Do Not Disturb and whether they set themselves away, | |
| 14 | 16 | * and when they were last on each workspace's Home page (src/visits.ts). | |
| 15 | 17 | * | |
| ⋯ | |||
| 35 | 37 | type FeedSeed, | |
| 36 | 38 | type NotifyPreferences, | |
| 37 | 39 | type NotifyStatus, | |
| 40 | + | type PushDevice, | |
| 38 | 41 | type PushSubscriptionJson, | |
| 39 | 42 | } from "@g1t/contracts"; | |
| 40 | 43 | ||
| 41 | 44 | 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"; | |
| 43 | 47 | import { | |
| 44 | 48 | OFFLINE_GRACE_MS, | |
| 45 | 49 | applyChange, | |
| ⋯ | |||
| 62 | 66 | VAPID_PUBLIC_KEY?: string; | |
| 63 | 67 | VAPID_PRIVATE_KEY?: string; | |
| 64 | 68 | 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; | |
| 65 | 75 | /** One presence room per workspace (src/room.ts); without it, presence stays in the person's own tabs. */ | |
| 66 | 76 | ROOMS?: DurableObjectNamespace<Room>; | |
| 67 | 77 | }; | |
| ⋯ | |||
| 81 | 91 | const MAX_SUBSCRIPTIONS = 20; | |
| 82 | 92 | /** The longest endpoint taken. */ | |
| 83 | 93 | const MAX_ENDPOINT = 2000; | |
| 94 | + | /** The most phones one person gets pushes on. */ | |
| 95 | + | const MAX_DEVICES = 10; | |
| 84 | 96 | ||
| 85 | 97 | export class Feed extends DurableObject<FeedEnv> { | |
| 86 | 98 | private readonly sql: SqlStorage; | |
| ⋯ | |||
| 98 | 110 | this.sql.exec( | |
| 99 | 111 | `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)`, | |
| 100 | 112 | ); | |
| 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)`); | |
| 101 | 115 | this.sql.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); | |
| 102 | 116 | // When the person was last on each workspace's Home page (src/visits.ts). | |
| 103 | 117 | this.sql.exec(`CREATE TABLE IF NOT EXISTS visits (workspace TEXT PRIMARY KEY, seen_at INTEGER NOT NULL, previous_at INTEGER)`); | |
| ⋯ | |||
| 149 | 163 | return this.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM subscriptions").one().n; | |
| 150 | 164 | } | |
| 151 | 165 | ||
| 166 | + | private deviceCount(): number { | |
| 167 | + | return this.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM devices").one().n; | |
| 168 | + | } | |
| 169 | + | ||
| 152 | 170 | // ── Presence ──────────────────────────────────────────────────────────── | |
| 153 | 171 | ||
| 154 | 172 | private kept(): Kept { | |
| ⋯ | |||
| 399 | 417 | return true; | |
| 400 | 418 | } | |
| 401 | 419 | ||
| 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. */ | |
| 403 | 421 | private async tell(notification: FeedNotification, test = false): Promise<number> { | |
| 404 | 422 | 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(); | |
| 406 | 425 | const now = Date.now(); | |
| 407 | 426 | // Do Not Disturb: kept and counted, neither toasted nor pushed. | |
| 408 | 427 | const quiet = dndOn(this.kept().dnd_until, now); | |
| ⋯ | |||
| 411 | 430 | return decision.push ? this.push(notification) : 0; | |
| 412 | 431 | } | |
| 413 | 432 | ||
| 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 | + | ||
| 414 | 445 | /** 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> { | |
| 416 | 447 | const vapid = this.vapid(); | |
| 417 | 448 | if (!vapid) return 0; | |
| 418 | 449 | const subscriptions = this.sql.exec<{ endpoint: string; p256dh: string; auth: string }>("SELECT endpoint, p256dh, auth FROM subscriptions").toArray(); | |
| 419 | − | const payload = pushPayload(notification); | |
| 420 | 450 | const results = await Promise.allSettled( | |
| 421 | 451 | subscriptions.map((s) => sendPush(s, payload, { vapid, urgency: payload.urgent ? "high" : "normal", topic: payload.tag, ttl: 12 * 3600 })), | |
| 422 | 452 | ); | |
| ⋯ | |||
| 433 | 463 | return sent; | |
| 434 | 464 | } | |
| 435 | 465 | ||
| 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 | + | ||
| 436 | 476 | // ── RPC (called by the Worker, src/index.ts) ──────────────────────────── | |
| 437 | 477 | ||
| 438 | 478 | async notify(value: unknown): Promise<{ ok: boolean }> { | |
| ⋯ | |||
| 509 | 549 | return { ok: true }; | |
| 510 | 550 | } | |
| 511 | 551 | ||
| 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 | + | ||
| 512 | 573 | async status(endpoint: string | null): Promise<NotifyStatus> { | |
| 513 | 574 | 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 | + | }; | |
| 515 | 582 | } | |
| 516 | 583 | ||
| 517 | 584 | async setPreferences(change: unknown): Promise<NotifyPreferences> { | |
| 1 | 1 | /** | |
| 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). | |
| 3 | 4 | * One feed per person (src/feed.ts). | |
| 4 | 5 | * | |
| 5 | 6 | * Reached through service bindings only: `POST /rpc/<method>` with | |
| ⋯ | |||
| 13 | 14 | * - events: `notify`, for every inbox item, and `set_inbox`, the count after | |
| 14 | 15 | * items arrive or are marked, naming the person by username; | |
| 15 | 16 | * - 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 | |
| 17 | 20 | * `last_visit` and `mark_visit`, when they were last on a workspace's | |
| 18 | 21 | * Home page (src/visits.ts); | |
| 19 | 22 | * - agents and the site: `workspace_presence`, how a workspace's people | |
| ⋯ | |||
| 96 | 99 | return Response.json(await stub.subscribe(args.subscription, typeof args.user_agent === "string" ? args.user_agent : null)); | |
| 97 | 100 | case "unsubscribe": | |
| 98 | 101 | 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 ?? ""))); | |
| 99 | 106 | case "status": | |
| 100 | 107 | return Response.json(await stub.status(typeof args.endpoint === "string" ? args.endpoint : null)); | |
| 101 | 108 | case "set_preferences": | |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.