Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More.
The app signs in with two new scopes, chat:read and chat:write. Chat has no API of its own, so the site now takes a person's own token holding them by name, without "Use the website as you", on Chat's JSON routes, its live socket and the feed's tickets, and notifications:* on /-/notify, and nowhere else: never a page or a page's data, never an artifact's socket. A full-access token does not count. No preset holds the chat scopes, so an application gets Chat only by asking for it. Phones get pushes: the notify service keeps each person's devices beside their browsers and sends through Expo's push service (FCM and APNs) under the same rules as browser push, dropping a device Expo says is gone. The app registers with POST /-/notify register_device after asking once, opens the thread or conversation a tapped notification is about, and unregisters on signing out. The app config has the EAS project, the Android notification icon and channel, and reads Firebase's google-services.json from an EAS file variable. The authentication, notifications, phone app, deploy and self-hosting guides say so.
- Claude-Session:
- https://claude.ai/code/session_01MQE64a4WNN14F8GELCiuc9
| 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 |
| 1 | 1 | --- | |
| 2 | 2 | title: The phone app | |
| 3 | − | description: g1t on Android and iOS, built with Expo. Chat, Code and Customize on your phone, signed in with g1t's own OAuth. Not in the stores yet; this is how it works and how to build it. | |
| 3 | + | description: g1t on Android and iOS, built with Expo. Chat with people and agents, live, with notifications on your phone, and Code and Customize, signed in with g1t's own OAuth. Not in the stores yet; this is how it works and how to build it. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | The phone app is g1t on Android and iOS, with its own screens for what | |
| 7 | − | you do away from a desk: answering in Chat, reviewing what agents made in | |
| 8 | − | Code, and seeing the skills, connectors and plugins your workspace can | |
| 9 | − | use in Customize. Everything else opens on the site, in your phone's | |
| 10 | − | browser. | |
| 7 | + | you do away from a desk: talking with your team and its agents in Chat, | |
| 8 | + | with notifications on your phone, reviewing what agents made in Code, | |
| 9 | + | and seeing the skills, connectors and plugins your workspace can use in | |
| 10 | + | Customize. Everything else opens on the site, in your phone's browser. | |
| 11 | 11 | ||
| 12 | 12 | It is not in Google Play or the App Store yet. Android comes first. Until | |
| 13 | 13 | then, it is built from `apps/mobile` in | |
| 14 | 14 | [g1t's repository](https://g1t.sh/flagon-io/g1t), as | |
| 15 | 15 | [For operators](#for-operators-building-and-releasing) says. | |
| 16 | 16 | ||
| 17 | + | The bar along the bottom is the site's phone bar: | |
| 18 | + | ||
| 17 | 19 | | Tab | What is on it | | |
| 18 | 20 | | --- | --- | | |
| 19 | − | | **Chat** | The channels you are in and your direct messages, with unread counts. A conversation shows its messages and agents' cards, live while it is open, and you write back from the box at the bottom. | | |
| 20 | − | | **Code** | The workspace's repositories, each with its pull requests and issues, open or closed. A pull request shows its description, checks, conversation and changed files, and you can approve it or ask for changes. An issue shows the pull requests made for it, and you can comment. | | |
| 21 | − | | **Customize** | **Skills** (what agents know how to do), **Connectors** (the tools they can reach, the Marketplace's integrations today) and **Plugins** (what adds pages and tools, the Marketplace's extensions today). Each opens where it is set up on the site. | | |
| 22 | − | | **You** | Your workspaces, to switch between them, how you signed in, and **Sign out**. | | |
| 21 | + | | **Home** | Coming. Opens Home on the site meanwhile. | | |
| 22 | + | | **Chat** | Your starred conversations, channels and direct messages, with what is unread, moving live. See [Chat](#chat). | | |
| 23 | + | | **Notifications** | Coming. Opens your notifications on the site meanwhile; messages to you already arrive [on the phone](#notifications). | | |
| 24 | + | | **Agents** | Coming. Opens Agents on the site meanwhile; message any agent from Chat. | | |
| 25 | + | | **Code** | The workspace's repositories, each with its pull requests and issues. A pull request shows its description, checks, conversation and changed files, and you can approve it or ask for changes. An issue shows the pull requests made for it, and you can comment. Shown when you use Code in the workspace. | | |
| 26 | + | | **More** | **Customize** (skills, connectors and plugins), whether this phone gets notifications, your workspaces, how you signed in, and **Sign out**. | | |
| 27 | + | ||
| 28 | + | ## Chat | |
| 29 | + | ||
| 30 | + | Chat on the phone is the site's Chat: the same conversations, as they | |
| 31 | + | happen. | |
| 23 | 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 | + | ||
| 24 | 71 | ## Signing in | |
| 25 | 72 | ||
| 26 | 73 | **Sign in with g1t** opens g1t.sh in your phone's browser, where you sign | |
| ⋯ | |||
| 41 | 88 | **Use an access token** signs in with a | |
| 42 | 89 | [personal access token](/guides/authentication/#access-tokens) you paste | |
| 43 | 90 | in instead. The token stays on your phone, in its keystore. | |
| 44 | − | ||
| 45 | − | ### Chat and the app's sign-in | |
| 46 | 91 | ||
| 47 | − | Chat is served by the site, and the site takes only a browser's session | |
| 48 | − | or a token with **Use the website as you** turned on. The app's OAuth | |
| 49 | − | sign-in is neither yet, so with it Code and Customize work and Chat says | |
| 50 | − | it needs another sign-in. Until the app's sign-in opens Chat, sign in | |
| 51 | − | with **Use an access token** and a token with | |
| 52 | − | [Use the website as you](/guides/authentication/#use-a-token-on-the-website) | |
| 53 | − | turned on. Keep that token as safe as your password: on the website it can | |
| 54 | − | do anything you can. | |
| 92 | + | The app asks for `chat:read` and `chat:write` too. They let it use | |
| 93 | + | Chat's routes and live connections on the site, and register for | |
| 94 | + | notifications, and nothing else there: the app's sign-in cannot open the | |
| 95 | + | site's pages as you. | |
| 55 | 96 | ||
| 56 | 97 | ## Links | |
| 57 | 98 | ||
| ⋯ | |||
| 79 | 120 | ## What's not there yet | |
| 80 | 121 | ||
| 81 | 122 | - **Store releases.** Google Play first, then the App Store. | |
| 82 | − | - **Notifications on your phone.** g1t sends browser and desktop | |
| 83 | − | notifications today; push to phones needs g1t to send through Firebase | |
| 84 | − | and Apple, which is coming. | |
| 85 | − | - **Chat with the app's own sign-in**, as [above](#chat-and-the-apps-sign-in). | |
| 86 | 123 | - **Home, Notifications, Agents and Artifacts** as tabs of their own. | |
| 87 | 124 | Their pages open on the site meanwhile. | |
| 88 | − | - **Formatting.** Messages, descriptions and comments show their Markdown | |
| 89 | − | as written. | |
| 90 | − | - **Threads.** A message's replies are counted, and read on the site. | |
| 125 | + | - **Formatting in Code.** Descriptions and comments show their Markdown | |
| 126 | + | as written; messages in Chat are formatted. | |
| 127 | + | - **Attachments and custom emoji.** Files in messages, and a | |
| 128 | + | workspace's own `:emoji:`, show as their names. | |
| 129 | + | - **Replying from a notification**, without opening the app. | |
| 91 | 130 | - **https links.** `g1t.sh` addresses open the site in the browser, not | |
| 92 | 131 | the app, until g1t.sh publishes the files Android and iOS check first. | |
| 93 | 132 | ||
| 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 | |
| 40 | 40 | # generated native folders | |
| 41 | 41 | /ios | |
| 42 | 42 | /android | |
| 43 | + | google-services.json |
| 17 | 17 | sign-in.tsx Sign in with g1t, an access token, or another server | |
| 18 | 18 | oauth/callback.tsx where the site sends sign-in back (g1t://oauth/callback) | |
| 19 | 19 | (app)/ signed in: the tabs | |
| 20 | − | chat/ conversations, and one conversation, live | |
| 20 | + | chat/ the list, a conversation, a thread, new, browse | |
| 21 | 21 | code/ repositories, pull requests, issues, diffs | |
| 22 | − | customize/ Skills, Connectors, Plugins | |
| 23 | − | you/ workspaces, how you signed in, sign out | |
| 22 | + | more/ Customize, notifications, workspaces, sign out | |
| 23 | + | customize/ Skills, Connectors, Plugins (from More) | |
| 24 | + | home/, notifications/, agents/ coming: they open the site | |
| 24 | 25 | +native-intent.tsx g1t:// links to screens (lib/links.ts) | |
| 25 | − | components/ the building blocks, themed (ui.tsx), and Code's | |
| 26 | + | components/ the building blocks, themed (ui.tsx); chat/ and Code's | |
| 26 | 27 | lib/ | |
| 27 | 28 | server.ts which g1t: site and API addresses | |
| 28 | 29 | oauth.ts OAuth 2.1 with PKCE against the server's metadata | |
| ⋯ | |||
| 30 | 31 | client.ts every request: refresh, errors, the site or the API | |
| 31 | 32 | session.tsx who is signed in, to which workspace | |
| 32 | 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 | |
| 33 | 37 | customize.ts Customize's listings, from @g1t/contracts' catalogs | |
| 34 | 38 | theme/ packages/theme/tokens.css's colours, for React Native | |
| 35 | 39 | ``` | |
| ⋯ | |||
| 45 | 49 | | --- | --- | --- | | |
| 46 | 50 | | Who you are, your workspaces | `GET {api}/user` | `Authorization: Bearer g1t_…` | | |
| 47 | 51 | | Code | `{api}/repos…` REST | the same | | |
| 48 | − | | Chat | `{site}/<workspace>/-/chat/api` and its live socket, as the site's own pages use them | the same header, which the site takes only from a token with **Use the website as you**; the socket gets a ticket from `{site}/-/live/ticket` first | | |
| 52 | + | | Chat | `{site}/<workspace>/-/chat/api` and its live socket, as the site's own pages use them | the same header, with `chat:*` ([below](#chat-with-the-apps-sign-in)); a socket gets a ticket from `{site}/-/live/ticket` first | | |
| 53 | + | | Notifications | `{site}/-/notify` and the feed socket `{site}/-/live` | the same, with `notifications:*` | | |
| 49 | 54 | | Sign-in | `{api}/.well-known/oauth-authorization-server`, `{site}/oauth/authorize`, `{api}/oauth/token` | OAuth 2.1, PKCE (S256), public client | | |
| 50 | 55 | ||
| 51 | 56 | The OAuth client is not registered anywhere: its id encodes its name and | |
| ⋯ | |||
| 55 | 60 | token works once, so `lib/client.ts` shares one refresh between | |
| 56 | 61 | requests. | |
| 57 | 62 | ||
| 58 | − | ### Open: Chat with the app's OAuth sign-in | |
| 63 | + | ### Chat with the app's sign-in | |
| 64 | + | ||
| 65 | + | Chat has no REST API: the app uses the site's own chat routes. The app | |
| 66 | + | asks for `chat:read`, `chat:write`, `notifications:read` and | |
| 67 | + | `notifications:write`, and the site takes a token holding them by name | |
| 68 | + | (OAuth or personal, without "Use the website as you") on these alone | |
| 69 | + | (`apps/web/app/lib/website-token.ts`, `scopedRoute` and `socketUser`): | |
| 70 | + | ||
| 71 | + | | Route | Needs | | |
| 72 | + | | --- | --- | | |
| 73 | + | | `GET`, `POST /<ws>/-/chat/api` | `chat:read`, `chat:write` | | |
| 74 | + | | `GET /<ws>/-/chat/person/<username>` | `chat:read` | | |
| 75 | + | | `GET /-/live/ticket` for `/<ws>/-/chat/live` and `/-/live` | `chat:read` | | |
| 76 | + | | `GET`, `POST /-/notify` | `notifications:read`, `notifications:write` | | |
| 59 | 77 | ||
| 60 | − | OAuth tokens are made without the website permission | |
| 61 | − | (`services/identity/src/oauth.rs` → `issue_oauth_tokens`), and Chat is | |
| 62 | − | only on the site, so **an OAuth sign-in reaches Code but not Chat**. The | |
| 63 | − | app says so on Chat and offers an access-token sign-in, which works today | |
| 64 | − | with a token that has **Use the website as you**. The server needs one of | |
| 65 | − | these before the app ships: | |
| 78 | + | A full-access token does not count: it reaches Chat only with the website | |
| 79 | + | permission. | |
| 66 | 80 | ||
| 67 | − | 1. **Chat in the REST API** (recommended): `GET/POST /workspaces/:ws/chat…` | |
| 68 | − | in `apps/api` with `chat:read` and `chat:write` scopes, calling the chat | |
| 69 | − | service as the site does, and a socket ticket the API can mint. It is | |
| 70 | − | what the API and MCP will want anyway; the app moves its three calls | |
| 71 | − | in `lib/chat.ts` over. | |
| 72 | − | 2. **Let the site take OAuth tokens for Chat's routes alone**: | |
| 73 | − | `website-token.ts` accepts a token without `website` on | |
| 74 | − | `/<ws>/-/chat/api`, `/<ws>/-/chat/live` and `/-/live/ticket` when it | |
| 75 | − | has new `chat:*` scopes. Smaller, but it widens what the site accepts. | |
| 81 | + | ### Live, and notifications | |
| 76 | 82 | ||
| 77 | − | Turning `website` on for every OAuth token is not one of them: it would | |
| 78 | − | let any approved application use the whole site as the person. | |
| 83 | + | - **A conversation's socket** (`lib/chat.ts`, `useChannelLive`) brings new, | |
| 84 | + | changed and deleted messages, reactions and typing, and takes the | |
| 85 | + | app's own typing. | |
| 86 | + | - **The feed** (`lib/feed.tsx`), the socket every tab of the site keeps, | |
| 87 | + | is open while the app is in front: unread counts, notifications (a | |
| 88 | + | banner, `components/chat/banner.tsx`) and presence. It tells g1t the app | |
| 89 | + | is focused, so nothing is pushed to the phone while it is in use; in the | |
| 90 | + | background it closes. | |
| 91 | + | - **Push** (`lib/push.ts`): the app registers its Expo push token with | |
| 92 | + | `POST /-/notify` `{ intent: "register_device", device: { token, platform, name } }` | |
| 93 | + | and takes it back on signing out (`unregister_device`). The notify | |
| 94 | + | service sends through Expo's push service on the Android channel | |
| 95 | + | `messages`, with `data` `{ href, workspace, kind, tag, channel_id, thread_root, card }`; | |
| 96 | + | tapping one opens its thread or conversation. | |
| 79 | 97 | ||
| 80 | 98 | ## Run it | |
| 81 | 99 | ||
| ⋯ | |||
| 102 | 120 | call it **g1t in Expo Go**. Its client id encodes that address, so a new | |
| 103 | 121 | network makes a new client: the app keeps the id it signed in with for | |
| 104 | 122 | refreshing, and signing in again after a move just adds a new entry to | |
| 105 | − | Connected applications. What Go can't do: open from `g1t://` links (they | |
| 106 | − | go to an installed build) and show the app's own name and icon. | |
| 123 | + | Connected applications. Chat works in Go in full, live, with banners | |
| 124 | + | while the app is open. What Go can't do: notifications on an Android | |
| 125 | + | phone (Expo took push out of Go there), open from `g1t://` links (they go | |
| 126 | + | to an installed build), and show the app's own name and icon. | |
| 107 | 127 | ||
| 108 | 128 | To develop against a g1t running on your computer, choose **Server** on | |
| 109 | 129 | the sign-in screen and give its address on your network, such as | |
| ⋯ | |||
| 139 | 159 | Once, by someone with access to the Expo account and Google Play: | |
| 140 | 160 | ||
| 141 | 161 | 1. `npm install -g eas-cli`, then `eas login`. | |
| 142 | − | 2. In `apps/mobile`, `eas init`. It makes the EAS project and prints its | |
| 143 | − | id: put it in `EAS_PROJECT` in `app.config.ts` (the config is code, so | |
| 144 | − | `eas init` cannot write it there). Set `EAS_OWNER` in your shell if the | |
| 145 | − | project belongs to an organization. | |
| 162 | + | 2. The EAS project exists (`35fb457b-…`, `EAS_PROJECT` in | |
| 163 | + | `app.config.ts`); `eas whoami` should be an account with access to it. | |
| 164 | + | Set `EAS_OWNER` in your shell if it belongs to an organization. On | |
| 165 | + | expo.dev, the project's GitHub settings take `apps/mobile` as the base | |
| 166 | + | directory. | |
| 146 | 167 | 3. `eas build --profile preview --platform android`. EAS makes and keeps | |
| 147 | 168 | the upload keystore the first time; say yes. The build ends with a link | |
| 148 | 169 | and a QR code to install the APK. | |
| ⋯ | |||
| 160 | 181 | iOS is the same with `--platform ios`, an Apple Developer account and the | |
| 161 | 182 | bundle id `sh.g1t.app`; EAS makes the certificates. | |
| 162 | 183 | ||
| 184 | + | ### Push notifications on Android | |
| 185 | + | ||
| 186 | + | Expo's push service sends to Android through Firebase Cloud Messaging, | |
| 187 | + | which needs a Firebase project. Once: | |
| 188 | + | ||
| 189 | + | 1. In the [Firebase console](https://console.firebase.google.com), make a | |
| 190 | + | project, and in it an Android app for each package: `sh.g1t.app`, | |
| 191 | + | `sh.g1t.app.preview` and `sh.g1t.app.dev`. Download | |
| 192 | + | `google-services.json` (one file lists all three). | |
| 193 | + | 2. Give it to EAS as a file variable: | |
| 194 | + | `eas env:create --scope project --name GOOGLE_SERVICES_JSON --type file --value ./google-services.json --visibility secret --environment production --environment preview --environment development`. | |
| 195 | + | `app.config.ts` reads it from there, or a `google-services.json` beside | |
| 196 | + | it locally (ignored by git). | |
| 197 | + | 3. In Firebase, **Project settings → Service accounts → Generate new private | |
| 198 | + | key**, then `eas credentials` → Android → the production build's package → | |
| 199 | + | **Google Service Account → Push Notifications (FCM V1)**, and upload that | |
| 200 | + | key. Repeat for `sh.g1t.app.preview` and `sh.g1t.app.dev`. | |
| 201 | + | 4. Build again. Pushes need a build: Expo Go on Android has none. | |
| 202 | + | ||
| 203 | + | `EXPO_ACCESS_TOKEN` on the notify Worker is needed only if the Expo | |
| 204 | + | project turns on **Enhanced push security**. | |
| 205 | + | ||
| 163 | 206 | ### Before the first store release | |
| 164 | 207 | ||
| 165 | − | - **Chat with OAuth**, above. | |
| 166 | − | - **Push notifications**: `expo-notifications`, and in `services/notify` | |
| 167 | − | a subscription kind for Expo push tokens (or FCM and APNs) beside Web | |
| 168 | − | Push, registered from the app after sign-in. | |
| 169 | 208 | - **App Links**: `https://g1t.sh/.well-known/assetlinks.json` naming | |
| 170 | 209 | `sh.g1t.app` and Play's signing certificate's SHA-256, then an | |
| 171 | 210 | `intentFilters` entry with `autoVerify` in `app.config.ts`, so g1t.sh | |
| ⋯ | |||
| 177 | 216 | agent; the site's `AgentFace` should be shared and drawn here. | |
| 178 | 217 | - **The store listing**: screenshots, a privacy policy URL, and Play's | |
| 179 | 218 | data safety form (the app keeps the server and credentials in the | |
| 180 | − | keystore, and sends nothing anywhere but the g1t it is pointed at). | |
| 219 | + | keystore, registers a push token with the g1t it is pointed at, and | |
| 220 | + | sends nothing anywhere else). | |
| 181 | 221 | ||
| 182 | 222 | ## Notes | |
| 183 | 223 | ||
| 1 | + | /// <reference types="node" /> | |
| 2 | + | ||
| 3 | + | import { existsSync } from "node:fs"; | |
| 4 | + | import { join } from "node:path"; | |
| 5 | + | ||
| 1 | 6 | import type { ConfigContext, ExpoConfig } from "expo/config"; | |
| 2 | 7 | ||
| 3 | 8 | /** | |
| ⋯ | |||
| 11 | 16 | * | `preview` | `sh.g1t.app.preview` | g1t (beta) | | |
| 12 | 17 | * | production | `sh.g1t.app` | g1t | | |
| 13 | 18 | * | |
| 14 | − | * The EAS project is set once `eas init` has made one (README.md); until | |
| 15 | − | * then, builds and updates are not linked to one. | |
| 19 | + | * The EAS project is g1t's on expo.dev; push notifications on Android | |
| 20 | + | * also need the Firebase credentials uploaded there (README.md). | |
| 16 | 21 | */ | |
| 17 | 22 | ||
| 18 | 23 | /** The EAS project's id, from `eas init`. `EAS_PROJECT_ID` overrides it. */ | |
| 19 | − | const EAS_PROJECT: string | undefined = undefined; | |
| 24 | + | const EAS_PROJECT = "35fb457b-905c-44cd-9b35-0899e12852dd"; | |
| 20 | 25 | ||
| 21 | 26 | const variant = process.env.APP_VARIANT; | |
| 22 | 27 | const suffix = variant === "development" ? ".dev" : variant === "preview" ? ".preview" : ""; | |
| ⋯ | |||
| 26 | 31 | ||
| 27 | 32 | /** g1t's background, dark as the site is by default (packages/theme/tokens.css `--g1t-bg`). */ | |
| 28 | 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); | |
| 29 | 44 | ||
| 30 | 45 | export default ({ config }: ConfigContext): ExpoConfig => ({ | |
| 31 | 46 | ...config, | |
| ⋯ | |||
| 52 | 67 | monochromeImage: "./assets/images/android-icon-monochrome.png", | |
| 53 | 68 | }, | |
| 54 | 69 | predictiveBackGestureEnabled: true, | |
| 70 | + | ...(googleServices ? { googleServicesFile: googleServices } : {}), | |
| 55 | 71 | softwareKeyboardLayoutMode: "resize", | |
| 56 | 72 | }, | |
| 57 | 73 | plugins: [ | |
| ⋯ | |||
| 59 | 75 | "expo-secure-store", | |
| 60 | 76 | "expo-web-browser", | |
| 61 | 77 | [ | |
| 78 | + | "expo-notifications", | |
| 79 | + | { | |
| 80 | + | icon: "./assets/images/notification-icon.png", | |
| 81 | + | color: ACCENT, | |
| 82 | + | defaultChannel: "messages", | |
| 83 | + | }, | |
| 84 | + | ], | |
| 85 | + | [ | |
| 62 | 86 | "expo-splash-screen", | |
| 63 | 87 | { | |
| 64 | 88 | backgroundColor: BACKGROUND, | |
Binary or large file; its contents are not shown.
| 25 | 25 | "expo-clipboard": "~57.0.2", | |
| 26 | 26 | "expo-constants": "~57.0.21", | |
| 27 | 27 | "expo-crypto": "~57.0.3", | |
| 28 | + | "expo-device": "~57.0.2", | |
| 28 | 29 | "expo-font": "~57.0.4", | |
| 30 | + | "expo-haptics": "~57.0.3", | |
| 29 | 31 | "expo-image": "~57.0.5", | |
| 30 | 32 | "expo-linking": "~57.0.12", | |
| 33 | + | "expo-notifications": "~57.0.22", | |
| 31 | 34 | "expo-router": "~57.0.25", | |
| 32 | 35 | "expo-secure-store": "~57.0.4", | |
| 33 | 36 | "expo-splash-screen": "~57.0.9", | |
| ⋯ | |||
| 45 | 48 | "react-native-worklets": "0.10.1" | |
| 46 | 49 | }, | |
| 47 | 50 | "devDependencies": { | |
| 51 | + | "@types/node": "^22.20.5", | |
| 48 | 52 | "@types/react": "~19.2.2", | |
| 49 | 53 | "sharp": "^0.35.5", | |
| 50 | 54 | "typescript": "~6.0.3" | |
| 6 | 6 | // middle 66% Android keeps whatever the launcher's mask. | |
| 7 | 7 | // - android-icon-monochrome.png: the same in white, for themed icons. | |
| 8 | 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). | |
| 9 | 11 | import { readFile } from "node:fs/promises"; | |
| 10 | 12 | import { fileURLToPath } from "node:url"; | |
| 11 | 13 | import sharp from "sharp"; | |
| ⋯ | |||
| 29 | 31 | await draw("android-icon-foreground.png", { size: 1024, scale: 0.46 }); | |
| 30 | 32 | await draw("android-icon-monochrome.png", { size: 1024, scale: 0.46, color: "#ffffff" }); | |
| 31 | 33 | await draw("splash-icon.png", { size: 512, scale: 0.9 }); | |
| 32 | − | console.log("Drew icon.png, android-icon-foreground.png, android-icon-monochrome.png and splash-icon.png."); | |
| 34 | + | await draw("notification-icon.png", { size: 96, scale: 0.8, color: "#ffffff" }); | |
| 35 | + | console.log("Drew icon.png, android-icon-foreground.png, android-icon-monochrome.png, splash-icon.png and notification-icon.png."); | |
| 1 | − | import { Tabs, useGlobalSearchParams } from "expo-router"; | |
| 2 | − | import { Blocks, CircleUser, Code2, MessagesSquare } from "lucide-react-native"; | |
| 3 | − | import { useEffect } from "react"; | |
| 1 | + | import * as Notifications from "expo-notifications"; | |
| 2 | + | import { Tabs, router, useGlobalSearchParams } from "expo-router"; | |
| 3 | + | import { Bell, Bot, Code2, House, Menu, MessagesSquare } from "lucide-react-native"; | |
| 4 | + | import { useEffect, useRef } from "react"; | |
| 4 | 5 | ||
| 6 | + | import { Banner } from "@/components/chat/banner"; | |
| 7 | + | import { FeedProvider, useFeed } from "@/lib/feed"; | |
| 8 | + | import { routeFor } from "@/lib/links"; | |
| 9 | + | import { type PushData, registerForPush } from "@/lib/push"; | |
| 5 | 10 | import { useSignedIn } from "@/lib/session"; | |
| 11 | + | import { hasCodeAccess } from "@g1t/contracts"; | |
| 6 | 12 | import { useColors } from "@/theme"; | |
| 7 | 13 | ||
| 8 | 14 | /** | |
| 9 | − | * The signed-in app: Chat, Code, Customize and You, as the site's phone | |
| 10 | − | * layout has its bottom bar (apps/web/app/components/mobile.tsx). Home, | |
| 11 | − | * Notifications, Agents and Artifacts join as they get screens. | |
| 15 | + | * The signed-in app. Its bar is the site's phone bar | |
| 16 | + | * (apps/web/app/components/mobile.tsx): Home, Chat, Notifications, Agents, | |
| 17 | + | * Code, and More. Chat and Code have their screens; the others say so and | |
| 18 | + | * open the site. Customize and your account are under More. | |
| 12 | 19 | */ | |
| 13 | 20 | export default function AppLayout() { | |
| 21 | + | return ( | |
| 22 | + | <FeedProvider> | |
| 23 | + | <PushSetup /> | |
| 24 | + | <AppTabs /> | |
| 25 | + | <Banner /> | |
| 26 | + | </FeedProvider> | |
| 27 | + | ); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | function AppTabs() { | |
| 14 | 31 | const colors = useColors(); | |
| 15 | 32 | const { workspace, selectWorkspace } = useSignedIn(); | |
| 33 | + | const { unread, mentions } = useFeed(); | |
| 16 | 34 | // A link names its workspace (`/chat?workspace=acme`, lib/links.ts): switch to it. | |
| 17 | 35 | const { workspace: linked } = useGlobalSearchParams<{ workspace?: string }>(); | |
| 18 | 36 | useEffect(() => { | |
| 19 | 37 | if (linked && linked.toLowerCase() !== workspace?.slug.toLowerCase()) void selectWorkspace(linked); | |
| 20 | 38 | }, [linked, workspace?.slug, selectWorkspace]); | |
| 39 | + | const code = !workspace || hasCodeAccess(workspace); | |
| 40 | + | const badge = mentions > 0 ? mentions : unread > 0 ? unread : undefined; | |
| 21 | 41 | ||
| 22 | 42 | return ( | |
| 23 | 43 | <Tabs | |
| 44 | + | initialRouteName="chat" | |
| 24 | 45 | screenOptions={{ | |
| 25 | 46 | headerShown: false, | |
| 26 | 47 | tabBarActiveTintColor: colors.accent, | |
| 27 | 48 | tabBarInactiveTintColor: colors.muted, | |
| 28 | 49 | tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.line }, | |
| 50 | + | tabBarLabelStyle: { fontSize: 11, fontWeight: "600" }, | |
| 29 | 51 | }} | |
| 30 | 52 | > | |
| 31 | 53 | <Tabs.Screen name="index" options={{ href: null }} /> | |
| 32 | − | <Tabs.Screen name="chat" options={{ title: "Chat", tabBarIcon: ({ color, size }) => <MessagesSquare color={color} size={size} /> }} /> | |
| 33 | − | <Tabs.Screen name="code" options={{ title: "Code", tabBarIcon: ({ color, size }) => <Code2 color={color} size={size} /> }} /> | |
| 34 | − | <Tabs.Screen name="customize" options={{ title: "Customize", tabBarIcon: ({ color, size }) => <Blocks color={color} size={size} /> }} /> | |
| 35 | − | <Tabs.Screen name="you" options={{ title: "You", tabBarIcon: ({ color, size }) => <CircleUser color={color} size={size} /> }} /> | |
| 54 | + | <Tabs.Screen name="home" options={{ title: "Home", tabBarIcon: ({ color, size }) => <House color={color} size={size} /> }} /> | |
| 55 | + | <Tabs.Screen | |
| 56 | + | name="chat" | |
| 57 | + | options={{ | |
| 58 | + | title: "Chat", | |
| 59 | + | tabBarIcon: ({ color, size }) => <MessagesSquare color={color} size={size} />, | |
| 60 | + | tabBarBadge: badge, | |
| 61 | + | tabBarBadgeStyle: { backgroundColor: mentions > 0 ? colors.accent : colors.lineStrong, color: mentions > 0 ? colors.onAccent : colors.fg, fontSize: 11 }, | |
| 62 | + | }} | |
| 63 | + | /> | |
| 64 | + | <Tabs.Screen name="notifications" options={{ title: "Notifications", tabBarIcon: ({ color, size }) => <Bell color={color} size={size} /> }} /> | |
| 65 | + | <Tabs.Screen name="agents" options={{ title: "Agents", tabBarIcon: ({ color, size }) => <Bot color={color} size={size} /> }} /> | |
| 66 | + | <Tabs.Screen name="code" options={{ title: "Code", href: code ? undefined : null, tabBarIcon: ({ color, size }) => <Code2 color={color} size={size} /> }} /> | |
| 67 | + | <Tabs.Screen name="customize" options={{ href: null }} /> | |
| 68 | + | <Tabs.Screen name="more" options={{ title: "More", tabBarIcon: ({ color, size }) => <Menu color={color} size={size} /> }} /> | |
| 36 | 69 | </Tabs> | |
| 37 | 70 | ); | |
| 38 | 71 | } | |
| 72 | + | ||
| 73 | + | /** | |
| 74 | + | * Registers the phone for notifications once signed in (asking the first | |
| 75 | + | * time), and opens what a tapped notification is about: the thread or | |
| 76 | + | * conversation, in its workspace. | |
| 77 | + | */ | |
| 78 | + | function PushSetup() { | |
| 79 | + | const { client, selectWorkspace } = useSignedIn(); | |
| 80 | + | const handled = useRef<string | null>(null); | |
| 81 | + | const response = Notifications.useLastNotificationResponse(); | |
| 82 | + | ||
| 83 | + | useEffect(() => { | |
| 84 | + | void registerForPush(client, true).catch((error: unknown) => console.warn("g1t: registering for notifications failed", error)); | |
| 85 | + | }, [client]); | |
| 86 | + | ||
| 87 | + | useEffect(() => { | |
| 88 | + | if (!response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return; | |
| 89 | + | const id = response.notification.request.identifier; | |
| 90 | + | if (handled.current === id) return; | |
| 91 | + | handled.current = id; | |
| 92 | + | const data = (response.notification.request.content.data ?? {}) as PushData; | |
| 93 | + | void (async () => { | |
| 94 | + | if (data.workspace) await selectWorkspace(data.workspace); | |
| 95 | + | if (data.channel_id && data.thread_root) { | |
| 96 | + | router.push({ pathname: "/chat/thread", params: { channel: data.channel_id, root: data.thread_root } }); | |
| 97 | + | } else if (data.channel_id) { | |
| 98 | + | router.push({ pathname: "/chat/[channel]", params: { channel: data.channel_id } }); | |
| 99 | + | } else if (data.href) { | |
| 100 | + | const route = routeFor(data.href); | |
| 101 | + | router.push((route ?? `/open?path=${encodeURIComponent(data.href)}`) as never); | |
| 102 | + | } | |
| 103 | + | })(); | |
| 104 | + | }, [response, selectWorkspace]); | |
| 105 | + | ||
| 106 | + | return null; | |
| 107 | + | } |
| 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 type { ChatMessage } from "@g1t/contracts"; | |
| 2 | − | import { Stack, useLocalSearchParams } from "expo-router"; | |
| 3 | − | import * as WebBrowser from "expo-web-browser"; | |
| 4 | − | import { SendHorizontal } from "lucide-react-native"; | |
| 5 | − | import { useEffect, useMemo, useState } from "react"; | |
| 6 | − | import { ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; | |
| 7 | − | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 1 | + | import { Stack, router, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import { BellOff, Bell, LogOut, MoreHorizontal, Star, StarOff } from "lucide-react-native"; | |
| 3 | + | import { useState } from "react"; | |
| 4 | + | import { Alert, Pressable, View } from "react-native"; | |
| 8 | 5 | ||
| 9 | − | import { Avatar, Badge, ErrorView, Loading, Screen, Text } from "@/components/ui"; | |
| 10 | − | import { markRead, useChannelLive, useChatSidebar, useMessages, usePostMessage } from "@/lib/chat"; | |
| 11 | − | import { siteUrl } from "@/lib/server"; | |
| 12 | − | import { useSignedIn } from "@/lib/session"; | |
| 13 | − | import { clock } from "@/lib/time"; | |
| 14 | − | import { radius, space, useColors } from "@/theme"; | |
| 6 | + | import { Conversation } from "@/components/chat/conversation"; | |
| 7 | + | import { Sheet, SheetAction } from "@/components/chat/sheet"; | |
| 8 | + | import { Empty, Loading, Screen, Text } from "@/components/ui"; | |
| 9 | + | import { useChatActions, useChatSidebar } from "@/lib/chat"; | |
| 10 | + | import { space, useColors } from "@/theme"; | |
| 15 | 11 | ||
| 16 | 12 | /** | |
| 17 | − | * A conversation: its messages, newest at the bottom, live while open, and | |
| 18 | − | * a box to write in. `channel` is a channel's id, or a channel's name when | |
| 19 | − | * a link named it (`/acme/-/chat/general`). | |
| 13 | + | * A conversation. `channel` is a channel's id, or a channel's name when a | |
| 14 | + | * link named it (`/acme/-/chat/general`). The ⋯ button stars, mutes or | |
| 15 | + | * leaves it. | |
| 20 | 16 | */ | |
| 21 | − | export default function Conversation() { | |
| 22 | − | const params = useLocalSearchParams<{ channel: string; title?: string }>(); | |
| 23 | − | const { client, workspace, server } = useSignedIn(); | |
| 17 | + | export default function ConversationScreen() { | |
| 18 | + | const params = useLocalSearchParams<{ channel: string }>(); | |
| 24 | 19 | const sidebar = useChatSidebar(); | |
| 20 | + | const colors = useColors(); | |
| 21 | + | const [menu, setMenu] = useState(false); | |
| 25 | 22 | const entry = sidebar.data?.entries.find((one) => one.channel.id === params.channel || one.channel.name === params.channel); | |
| 26 | 23 | // A link by name waits for the sidebar to say which channel it is. | |
| 27 | − | const channelId = entry?.channel.id ?? (params.channel.includes("_") ? params.channel : ""); | |
| 28 | − | const title = params.title ?? (entry ? (entry.channel.kind === "dm" ? entry.title : `#${entry.channel.name}`) : "Chat"); | |
| 29 | − | ||
| 30 | − | const messages = useMessages(channelId); | |
| 31 | − | const live = useChannelLive(channelId); | |
| 32 | − | const post = usePostMessage(channelId); | |
| 33 | − | const [draft, setDraft] = useState(""); | |
| 34 | − | const colors = useColors(); | |
| 35 | − | const insets = useSafeAreaInsets(); | |
| 36 | − | ||
| 37 | − | // Newest first, for a list drawn from the bottom up. | |
| 38 | − | const newestFirst = useMemo( | |
| 39 | − | () => (messages.data?.pages ?? []).flatMap((page) => [...page.messages].reverse()).filter((message) => !message.deleted_at), | |
| 40 | − | [messages.data], | |
| 41 | − | ); | |
| 42 | − | const newest = newestFirst[0]?.id; | |
| 43 | − | useEffect(() => { | |
| 44 | − | if (workspace && channelId && newest) void markRead(client, workspace.slug, channelId, newest); | |
| 45 | − | }, [client, workspace, channelId, newest]); | |
| 24 | + | const channelId = entry?.channel.id ?? (/^[a-z]+_/.test(params.channel) ? params.channel : ""); | |
| 25 | + | const actions = useChatActions(channelId); | |
| 26 | + | const dm = entry?.channel.kind === "dm"; | |
| 27 | + | const title = entry ? (dm ? entry.title : `#${entry.channel.name}`) : ""; | |
| 46 | 28 | ||
| 47 | − | const send = () => { | |
| 48 | − | const body = draft.trim(); | |
| 49 | − | if (!body || post.isPending) return; | |
| 50 | − | post.mutate(body, { onSuccess: () => setDraft("") }); | |
| 51 | − | }; | |
| 29 | + | if (!channelId) { | |
| 30 | + | return ( | |
| 31 | + | <Screen> | |
| 32 | + | <Stack.Screen options={{ title: "" }} /> | |
| 33 | + | {sidebar.isPending ? <Loading /> : <Empty title="Not in this conversation" body="It may be private, or you may need to join it first." />} | |
| 34 | + | </Screen> | |
| 35 | + | ); | |
| 36 | + | } | |
| 52 | 37 | ||
| 53 | 38 | return ( | |
| 54 | 39 | <Screen> | |
| 55 | − | <Stack.Screen options={{ title, headerRight: () => <LiveDot status={live} /> }} /> | |
| 56 | − | <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined} keyboardVerticalOffset={90}> | |
| 57 | − | {!channelId && sidebar.isPending ? ( | |
| 58 | − | <Loading /> | |
| 59 | − | ) : messages.isPending ? ( | |
| 60 | − | <Loading /> | |
| 61 | − | ) : messages.error ? ( | |
| 62 | − | <ErrorView error={messages.error} retry={() => messages.refetch()} /> | |
| 63 | − | ) : ( | |
| 64 | − | <FlatList | |
| 65 | − | inverted | |
| 66 | − | data={newestFirst} | |
| 67 | − | keyExtractor={(message) => message.id} | |
| 68 | − | renderItem={({ item }) => <Message message={item} site={server.site} />} | |
| 69 | − | onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()} | |
| 70 | − | onEndReachedThreshold={0.5} | |
| 71 | − | ListFooterComponent={messages.isFetchingNextPage ? <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} /> : null} | |
| 72 | − | contentContainerStyle={{ paddingVertical: space.md }} | |
| 73 | − | /> | |
| 74 | − | )} | |
| 75 | − | <View style={[styles.composer, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: Math.max(insets.bottom, space.sm) }]}> | |
| 76 | − | <TextInput | |
| 77 | − | value={draft} | |
| 78 | − | onChangeText={setDraft} | |
| 79 | − | placeholder={`Message ${title}`} | |
| 80 | − | placeholderTextColor={colors.faint} | |
| 81 | − | multiline | |
| 82 | − | style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]} | |
| 40 | + | <Stack.Screen | |
| 41 | + | options={{ | |
| 42 | + | headerTitle: () => ( | |
| 43 | + | <View> | |
| 44 | + | <Text style={{ fontWeight: "700" }} numberOfLines={1}> | |
| 45 | + | {title} | |
| 46 | + | </Text> | |
| 47 | + | {entry?.channel.topic ? ( | |
| 48 | + | <Text size="small" tone="muted" numberOfLines={1}> | |
| 49 | + | {entry.channel.topic} | |
| 50 | + | </Text> | |
| 51 | + | ) : null} | |
| 52 | + | </View> | |
| 53 | + | ), | |
| 54 | + | headerRight: () => | |
| 55 | + | entry ? ( | |
| 56 | + | <Pressable accessibilityLabel="Conversation options" hitSlop={10} onPress={() => setMenu(true)}> | |
| 57 | + | <MoreHorizontal size={22} color={colors.fg} /> | |
| 58 | + | </Pressable> | |
| 59 | + | ) : null, | |
| 60 | + | }} | |
| 61 | + | /> | |
| 62 | + | <Conversation channelId={channelId} thread={null} title={title || "this conversation"} /> | |
| 63 | + | <Sheet visible={menu} onClose={() => setMenu(false)}> | |
| 64 | + | <View style={{ paddingHorizontal: space.lg, paddingBottom: space.sm }}> | |
| 65 | + | <Text size="heading">{title}</Text> | |
| 66 | + | </View> | |
| 67 | + | <SheetAction | |
| 68 | + | icon={entry?.starred ? StarOff : Star} | |
| 69 | + | label={entry?.starred ? "Unstar" : "Star"} | |
| 70 | + | onPress={() => { | |
| 71 | + | setMenu(false); | |
| 72 | + | actions.preferences.mutate({ starred: !entry?.starred }); | |
| 73 | + | }} | |
| 74 | + | /> | |
| 75 | + | <SheetAction | |
| 76 | + | icon={entry?.muted ? Bell : BellOff} | |
| 77 | + | label={entry?.muted ? "Unmute" : "Mute"} | |
| 78 | + | onPress={() => { | |
| 79 | + | setMenu(false); | |
| 80 | + | actions.preferences.mutate({ muted: !entry?.muted }); | |
| 81 | + | }} | |
| 82 | + | /> | |
| 83 | + | {!dm ? ( | |
| 84 | + | <SheetAction | |
| 85 | + | icon={LogOut} | |
| 86 | + | label="Leave channel" | |
| 87 | + | danger | |
| 88 | + | onPress={() => { | |
| 89 | + | setMenu(false); | |
| 90 | + | Alert.alert(`Leave ${title}?`, "You can join it again from Browse.", [ | |
| 91 | + | { text: "Cancel", style: "cancel" }, | |
| 92 | + | { text: "Leave", style: "destructive", onPress: () => actions.leave.mutate(undefined, { onSuccess: () => router.back() }) }, | |
| 93 | + | ]); | |
| 94 | + | }} | |
| 83 | 95 | /> | |
| 84 | − | <Pressable accessibilityRole="button" accessibilityLabel="Send" onPress={send} disabled={!draft.trim() || post.isPending} style={styles.send}> | |
| 85 | − | {post.isPending ? <ActivityIndicator color={colors.accent} /> : <SendHorizontal color={draft.trim() ? colors.accent : colors.faint} size={22} />} | |
| 86 | − | </Pressable> | |
| 87 | − | </View> | |
| 88 | − | {post.error ? ( | |
| 89 | − | <Text tone="danger" size="small" style={{ paddingHorizontal: space.lg, paddingBottom: space.sm }}> | |
| 90 | − | {post.error.message} | |
| 91 | − | </Text> | |
| 92 | 96 | ) : null} | |
| 93 | − | </KeyboardAvoidingView> | |
| 97 | + | </Sheet> | |
| 94 | 98 | </Screen> | |
| 95 | 99 | ); | |
| 96 | 100 | } | |
| 97 | − | ||
| 98 | − | function Message({ message, site }: { message: ChatMessage; site: string }) { | |
| 99 | − | const colors = useColors(); | |
| 100 | − | const { author } = message; | |
| 101 | − | const card = message.kind === "card" ? message.card : null; | |
| 102 | − | return ( | |
| 103 | − | <View style={styles.message}> | |
| 104 | − | <Avatar name={author.display_name} uri={author.avatar ? `${site}/avatars/${author.avatar}` : null} agent={author.kind === "agent"} /> | |
| 105 | − | <View style={{ flex: 1, gap: 2 }}> | |
| 106 | − | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "baseline" }}> | |
| 107 | − | <Text style={{ fontWeight: "600" }}>{author.display_name}</Text> | |
| 108 | − | <Text size="small" tone="faint"> | |
| 109 | − | {clock(message.created_at)} | |
| 110 | − | {message.edited_at ? " · edited" : ""} | |
| 111 | − | </Text> | |
| 112 | − | </View> | |
| 113 | − | {message.body ? <Text selectable>{message.body}</Text> : null} | |
| 114 | − | {card ? ( | |
| 115 | − | <Pressable | |
| 116 | − | disabled={!card.href} | |
| 117 | − | onPress={() => card.href && WebBrowser.openBrowserAsync(siteUrl({ site, api: "" }, card.href))} | |
| 118 | − | style={[styles.card, { borderColor: colors.lineStrong, backgroundColor: colors.surface }]} | |
| 119 | − | > | |
| 120 | − | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}> | |
| 121 | − | <Text style={{ flex: 1, fontWeight: "600" }}>{card.title}</Text> | |
| 122 | − | {card.state ? <Badge label={card.state} /> : null} | |
| 123 | − | </View> | |
| 124 | − | {card.detail ? ( | |
| 125 | − | <Text size="small" tone="muted"> | |
| 126 | − | {card.detail} | |
| 127 | − | </Text> | |
| 128 | − | ) : null} | |
| 129 | − | {card.body ? ( | |
| 130 | − | <Text size="small" numberOfLines={6}> | |
| 131 | − | {card.body} | |
| 132 | − | </Text> | |
| 133 | − | ) : null} | |
| 134 | − | </Pressable> | |
| 135 | − | ) : null} | |
| 136 | − | {message.reply_count > 0 ? ( | |
| 137 | − | <Text size="small" tone="accent"> | |
| 138 | − | {message.reply_count === 1 ? "1 reply" : `${message.reply_count} replies`} | |
| 139 | − | </Text> | |
| 140 | − | ) : null} | |
| 141 | − | </View> | |
| 142 | − | </View> | |
| 143 | − | ); | |
| 144 | − | } | |
| 145 | − | ||
| 146 | − | function LiveDot({ status }: { status: "connecting" | "live" | "offline" }) { | |
| 147 | − | const colors = useColors(); | |
| 148 | − | const color = status === "live" ? colors.success : status === "connecting" ? colors.warn : colors.faint; | |
| 149 | − | return <View accessibilityLabel={status === "live" ? "Live" : status === "connecting" ? "Connecting" : "Offline"} style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />; | |
| 150 | − | } | |
| 151 | − | ||
| 152 | − | const styles = StyleSheet.create({ | |
| 153 | − | message: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.sm }, | |
| 154 | − | card: { borderWidth: 1, borderRadius: radius.md, padding: space.md, gap: space.xs, marginTop: space.xs }, | |
| 155 | − | composer: { flexDirection: "row", alignItems: "flex-end", gap: space.sm, paddingHorizontal: space.md, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth }, | |
| 156 | − | input: { flex: 1, minHeight: 40, maxHeight: 140, borderRadius: radius.lg, borderWidth: 1, paddingHorizontal: space.md, paddingVertical: space.sm, fontSize: 15 }, | |
| 157 | − | send: { width: 40, height: 40, alignItems: "center", justifyContent: "center" }, | |
| 158 | − | }); |
| 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 | 1 | import type { ChatSidebarEntry } from "@g1t/contracts"; | |
| 2 | 2 | import { Stack, router } from "expo-router"; | |
| 3 | − | import { Hash, Lock, MessagesSquare } from "lucide-react-native"; | |
| 4 | − | import { RefreshControl, SectionList, View } from "react-native"; | |
| 3 | + | import { Hash, Lock, MessagesSquare, Search, SquarePen } from "lucide-react-native"; | |
| 4 | + | import { useMemo, useState } from "react"; | |
| 5 | + | import { Pressable, RefreshControl, SectionList, StyleSheet, TextInput, View } from "react-native"; | |
| 5 | 6 | ||
| 6 | − | import { Avatar, Badge, Empty, ErrorView, Loading, Row, Screen, SectionHeader } from "@/components/ui"; | |
| 7 | + | import { Avatar, Badge, Button, Empty, ErrorView, Loading, Screen, SectionHeader, Text } from "@/components/ui"; | |
| 7 | 8 | import { useChatSidebar } from "@/lib/chat"; | |
| 9 | + | import { useFeed } from "@/lib/feed"; | |
| 8 | 10 | import { useSignedIn } from "@/lib/session"; | |
| 9 | 11 | import { ago } from "@/lib/time"; | |
| 10 | − | import { useColors } from "@/theme"; | |
| 12 | + | import { radius, space, useColors } from "@/theme"; | |
| 11 | 13 | ||
| 12 | − | /** Chat: the workspace's channels you are in, then your direct messages. */ | |
| 14 | + | /** | |
| 15 | + | * Chat: starred conversations, the channels you are in, and your direct | |
| 16 | + | * messages, with what is unread. Counts move live with the feed | |
| 17 | + | * (lib/feed.tsx); pull down to read everything again. | |
| 18 | + | */ | |
| 13 | 19 | export default function ChatList() { | |
| 14 | − | const { workspace, server } = useSignedIn(); | |
| 20 | + | const { workspace } = useSignedIn(); | |
| 15 | 21 | const sidebar = useChatSidebar(); | |
| 16 | 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 | + | ); | |
| 17 | 50 | ||
| 18 | − | const title = workspace?.name ?? workspace?.slug ?? "Chat"; | |
| 19 | 51 | if (!workspace) { | |
| 20 | 52 | return ( | |
| 21 | 53 | <Screen> | |
| 22 | − | <Stack.Screen options={{ title: "Chat" }} /> | |
| 54 | + | {header} | |
| 23 | 55 | <Empty icon={MessagesSquare} title="No workspace yet" body="Create or join a workspace on g1t, then come back." /> | |
| 24 | 56 | </Screen> | |
| 25 | 57 | ); | |
| 26 | 58 | } | |
| 27 | 59 | ||
| 28 | − | const entries = sidebar.data?.entries ?? []; | |
| 29 | − | const sections = [ | |
| 30 | − | { title: "Channels", data: entries.filter((entry) => entry.channel.kind === "channel" && !entry.channel.archived_at) }, | |
| 31 | − | { title: "Direct messages", data: entries.filter((entry) => entry.channel.kind === "dm") }, | |
| 32 | − | ].filter((section) => section.data.length > 0); | |
| 33 | − | ||
| 34 | 60 | return ( | |
| 35 | 61 | <Screen> | |
| 36 | − | <Stack.Screen options={{ title }} /> | |
| 62 | + | {header} | |
| 37 | 63 | {sidebar.isPending ? ( | |
| 38 | 64 | <Loading /> | |
| 39 | 65 | ) : sidebar.error ? ( | |
| ⋯ | |||
| 42 | 68 | <SectionList | |
| 43 | 69 | sections={sections} | |
| 44 | 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 | + | } | |
| 45 | 87 | renderSectionHeader={({ section }) => <SectionHeader title={section.title} />} | |
| 88 | + | renderItem={({ item }) => <Conversation entry={item} />} | |
| 46 | 89 | refreshControl={<RefreshControl refreshing={sidebar.isRefetching} onRefresh={() => sidebar.refetch()} tintColor={colors.accent} />} | |
| 47 | − | ListEmptyComponent={<Empty icon={MessagesSquare} title="No conversations yet" body="Join a channel or message someone on g1t." />} | |
| 48 | − | renderItem={({ item }) => <Conversation entry={item} site={server.site} />} | |
| 49 | − | stickySectionHeadersEnabled={false} | |
| 90 | + | ListEmptyComponent={ | |
| 91 | + | search ? ( | |
| 92 | + | <Empty title="Nothing matches" /> | |
| 93 | + | ) : ( | |
| 94 | + | <Empty icon={MessagesSquare} title="No conversations yet" body="Message a person or an agent, or join a channel." action={<Button title="New message" onPress={() => router.push("/chat/new")} />} /> | |
| 95 | + | ) | |
| 96 | + | } | |
| 97 | + | ListFooterComponent={ | |
| 98 | + | sidebar.data && sidebar.data.browsable > 0 && !search ? ( | |
| 99 | + | <Pressable onPress={() => router.push("/chat/browse")} style={styles.browse}> | |
| 100 | + | <Hash size={16} color={colors.accent} /> | |
| 101 | + | <Text tone="accent" style={{ fontWeight: "600" }}> | |
| 102 | + | Browse {sidebar.data.browsable} more {sidebar.data.browsable === 1 ? "channel" : "channels"} | |
| 103 | + | </Text> | |
| 104 | + | </Pressable> | |
| 105 | + | ) : ( | |
| 106 | + | <View style={{ height: space.xl }} /> | |
| 107 | + | ) | |
| 108 | + | } | |
| 50 | 109 | /> | |
| 51 | 110 | )} | |
| 52 | 111 | </Screen> | |
| 53 | 112 | ); | |
| 54 | 113 | } | |
| 55 | 114 | ||
| 56 | − | function Conversation({ entry, site }: { entry: ChatSidebarEntry; site: string }) { | |
| 115 | + | function Conversation({ entry }: { entry: ChatSidebarEntry }) { | |
| 57 | 116 | const colors = useColors(); | |
| 117 | + | const { server } = useSignedIn(); | |
| 118 | + | const { presence } = useFeed(); | |
| 58 | 119 | const { channel } = entry; | |
| 59 | 120 | const dm = channel.kind === "dm"; | |
| 60 | 121 | const other = entry.others[0]; | |
| 61 | − | const leading = dm && other ? ( | |
| 62 | − | <Avatar name={other.display_name} uri={other.avatar ? `${site}/avatars/${other.avatar}` : null} agent={other.kind === "agent"} /> | |
| 63 | − | ) : ( | |
| 64 | − | <View style={{ width: 32, alignItems: "center" }}> | |
| 65 | − | {channel.private ? <Lock size={18} color={colors.muted} /> : <Hash size={18} color={colors.muted} />} | |
| 66 | − | </View> | |
| 67 | − | ); | |
| 68 | − | const unread = entry.unread > 0 && !entry.muted; | |
| 122 | + | const loud = entry.unread > 0 && !entry.muted; | |
| 123 | + | const online = dm && other && other.kind === "user" && presence.get(other.id) === "active"; | |
| 124 | + | const title = dm ? entry.title : (channel.name ?? entry.title); | |
| 125 | + | ||
| 69 | 126 | return ( | |
| 70 | − | <Row | |
| 71 | − | leading={leading} | |
| 72 | − | title={dm ? entry.title : channel.name ?? entry.title} | |
| 73 | − | subtitle={channel.topic ?? (channel.last_message_at ? ago(channel.last_message_at) : undefined)} | |
| 74 | − | trailing={ | |
| 75 | − | entry.mentions > 0 ? <Badge label={`@${entry.mentions}`} tone="accent" /> : unread ? <Badge label={String(entry.unread)} tone="neutral" /> : null | |
| 76 | − | } | |
| 77 | − | chevron={false} | |
| 78 | − | onPress={() => router.push({ pathname: "/chat/[channel]", params: { channel: channel.id, title: dm ? entry.title : `#${channel.name}` } })} | |
| 79 | − | /> | |
| 127 | + | <Pressable | |
| 128 | + | onPress={() => router.push({ pathname: "/chat/[channel]", params: { channel: channel.id } })} | |
| 129 | + | style={({ pressed }) => [styles.row, { backgroundColor: pressed ? colors.raised : "transparent" }]} | |
| 130 | + | > | |
| 131 | + | <View> | |
| 132 | + | {dm && other ? ( | |
| 133 | + | <Avatar name={other.display_name} uri={other.avatar ? `${server.site}/avatars/${other.avatar}` : null} agent={other.kind === "agent"} size={40} /> | |
| 134 | + | ) : ( | |
| 135 | + | <View style={[styles.icon, { backgroundColor: colors.raised }]}> | |
| 136 | + | {channel.private ? <Lock size={18} color={colors.muted} /> : <Hash size={18} color={colors.muted} />} | |
| 137 | + | </View> | |
| 138 | + | )} | |
| 139 | + | {online ? <View style={[styles.online, { backgroundColor: colors.success, borderColor: colors.bg }]} /> : null} | |
| 140 | + | </View> | |
| 141 | + | <View style={{ flex: 1, gap: 2 }}> | |
| 142 | + | <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}> | |
| 143 | + | <Text numberOfLines={1} style={{ flex: 1, fontWeight: loud ? "800" : "500", color: entry.muted ? colors.muted : colors.fg }}> | |
| 144 | + | {title} | |
| 145 | + | </Text> | |
| 146 | + | {channel.last_message_at ? ( | |
| 147 | + | <Text size="small" tone="faint"> | |
| 148 | + | {ago(channel.last_message_at)} | |
| 149 | + | </Text> | |
| 150 | + | ) : null} | |
| 151 | + | </View> | |
| 152 | + | <View style={{ flexDirection: "row", alignItems: "center", gap: space.sm }}> | |
| 153 | + | <Text size="small" tone="muted" numberOfLines={1} style={{ flex: 1 }}> | |
| 154 | + | {dm ? (other?.kind === "agent" ? (other.title ?? other.role ?? "Agent") : `@${other?.name ?? ""}`) : (channel.topic ?? (entry.muted ? "Muted" : " "))} | |
| 155 | + | </Text> | |
| 156 | + | {entry.mentions > 0 ? <Badge label={`@ ${entry.mentions}`} tone="accent" /> : loud ? <View style={[styles.dot, { backgroundColor: colors.accent }]} /> : null} | |
| 157 | + | </View> | |
| 158 | + | </View> | |
| 159 | + | </Pressable> | |
| 80 | 160 | ); | |
| 81 | 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 { 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 { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 1 | + | import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 2 | + | import Constants from "expo-constants"; | |
| 3 | + | import { Stack, router } from "expo-router"; | |
| 4 | + | import * as WebBrowser from "expo-web-browser"; | |
| 5 | + | import { Bell, Blocks, Check, ExternalLink, LogOut } from "lucide-react-native"; | |
| 6 | + | import { Linking, View } from "react-native"; | |
| 7 | + | ||
| 8 | + | import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| 9 | + | import { pushState, registerForPush } from "@/lib/push"; | |
| 10 | + | import { isHosted, siteUrl } from "@/lib/server"; | |
| 11 | + | import { useSignedIn } from "@/lib/session"; | |
| 12 | + | import { space, useColors } from "@/theme"; | |
| 13 | + | ||
| 14 | + | /** More: Customize, your workspaces, notifications on this phone, how you signed in, and signing out. */ | |
| 15 | + | export default function More() { | |
| 16 | + | const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn(); | |
| 17 | + | const colors = useColors(); | |
| 18 | + | const queries = useQueryClient(); | |
| 19 | + | const name = me.display_username ?? me.username; | |
| 20 | + | const oauth = client.credentials.kind === "oauth"; | |
| 21 | + | const push = useQuery({ queryKey: ["push-state"], queryFn: pushState }); | |
| 22 | + | ||
| 23 | + | const pushRow = { | |
| 24 | + | unsupported: { label: "Not here", detail: "Notifications need a build of the app on a phone: not Expo Go on Android, nor a simulator." }, | |
| 25 | + | denied: { label: "Off", detail: "Turned off for g1t in your phone's settings. Tap to open them." }, | |
| 26 | + | undetermined: { label: "Off", detail: "Tap to get notified of messages to you, mentions and agents waiting on you." }, | |
| 27 | + | granted: { label: "On", detail: "Messages to you, mentions, replies and agents waiting on you, when you're not at g1t elsewhere." }, | |
| 28 | + | }[push.data ?? "undetermined"]; | |
| 29 | + | ||
| 30 | + | return ( | |
| 31 | + | <Screen scroll style={{ paddingHorizontal: 0 }}> | |
| 32 | + | <Stack.Screen options={{ title: "More" }} /> | |
| 33 | + | <View style={{ flexDirection: "row", gap: space.md, alignItems: "center", paddingHorizontal: space.lg }}> | |
| 34 | + | <Avatar name={name} uri={me.avatar ? `${server.site}/avatars/${me.avatar}` : null} size={48} /> | |
| 35 | + | <View style={{ flex: 1 }}> | |
| 36 | + | <Text size="heading">{name}</Text> | |
| 37 | + | <Text size="small" tone="muted"> | |
| 38 | + | @{me.username} · {isHosted(server) ? "g1t.sh" : new URL(server.site).host} | |
| 39 | + | </Text> | |
| 40 | + | </View> | |
| 41 | + | </View> | |
| 42 | + | ||
| 43 | + | <View> | |
| 44 | + | <Row leading={<Blocks size={22} color={colors.accent} />} title="Customize" subtitle="Skills, connectors and plugins for your agents" onPress={() => router.push("/customize")} /> | |
| 45 | + | <Row | |
| 46 | + | leading={<Bell size={22} color={colors.accent} />} | |
| 47 | + | title="Notifications on this phone" | |
| 48 | + | subtitle={pushRow.detail} | |
| 49 | + | trailing={<Badge label={pushRow.label} tone={push.data === "granted" ? "success" : "neutral"} />} | |
| 50 | + | chevron={false} | |
| 51 | + | onPress={ | |
| 52 | + | push.data === "denied" | |
| 53 | + | ? () => void Linking.openSettings() | |
| 54 | + | : push.data === "undetermined" | |
| 55 | + | ? () => void registerForPush(client, true).finally(() => queries.invalidateQueries({ queryKey: ["push-state"] })) | |
| 56 | + | : undefined | |
| 57 | + | } | |
| 58 | + | /> | |
| 59 | + | </View> | |
| 60 | + | ||
| 61 | + | <View> | |
| 62 | + | <SectionHeader title="Workspaces" /> | |
| 63 | + | {(me.workspaces ?? []).map((one) => ( | |
| 64 | + | <Row | |
| 65 | + | key={one.slug} | |
| 66 | + | leading={<Avatar name={one.name ?? one.slug} uri={one.avatar ? `${server.site}/avatars/${one.avatar}` : null} />} | |
| 67 | + | title={one.name ?? one.slug} | |
| 68 | + | subtitle={`${one.slug} · ${one.role}`} | |
| 69 | + | trailing={one.slug === workspace?.slug ? <Check size={18} color={colors.accent} /> : null} | |
| 70 | + | chevron={false} | |
| 71 | + | onPress={() => selectWorkspace(one.slug)} | |
| 72 | + | /> | |
| 73 | + | ))} | |
| 74 | + | </View> | |
| 75 | + | ||
| 76 | + | <View> | |
| 77 | + | <SectionHeader title="Signed in" /> | |
| 78 | + | <Row | |
| 79 | + | title={oauth ? "With g1t" : "With an access token"} | |
| 80 | + | subtitle={oauth ? "End or narrow the app's access in Settings → Connected applications." : "The token you pasted. Delete it in Settings → Access tokens to end it."} | |
| 81 | + | trailing={<ExternalLink size={16} color={colors.faint} />} | |
| 82 | + | chevron={false} | |
| 83 | + | onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, oauth ? "/settings/applications" : "/settings/tokens"))} | |
| 84 | + | /> | |
| 85 | + | </View> | |
| 86 | + | ||
| 87 | + | <View style={{ paddingHorizontal: space.lg, gap: space.md }}> | |
| 88 | + | <Button title="Sign out" variant="danger" icon={LogOut} onPress={signOut} /> | |
| 89 | + | <Text size="small" tone="faint" style={{ textAlign: "center" }}> | |
| 90 | + | g1t {Constants.expoConfig?.version ?? ""} | |
| 91 | + | </Text> | |
| 92 | + | </View> | |
| 93 | + | </Screen> | |
| 94 | + | ); | |
| 95 | + | } |
| 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 { TabStack } from "@/components/stack"; | |
| 2 | − | ||
| 3 | − | export default TabStack; |
| 1 | − | import Constants from "expo-constants"; | |
| 2 | − | import { Stack } from "expo-router"; | |
| 3 | − | import * as WebBrowser from "expo-web-browser"; | |
| 4 | − | import { Check, ExternalLink, LogOut } from "lucide-react-native"; | |
| 5 | − | import { View } from "react-native"; | |
| 6 | − | ||
| 7 | − | import { Avatar, Button, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| 8 | − | import { isHosted, siteUrl } from "@/lib/server"; | |
| 9 | − | import { useSignedIn } from "@/lib/session"; | |
| 10 | − | import { space, useColors } from "@/theme"; | |
| 11 | − | ||
| 12 | − | /** You: who is signed in, the workspace the app shows, and signing out. */ | |
| 13 | − | export default function You() { | |
| 14 | − | const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn(); | |
| 15 | − | const colors = useColors(); | |
| 16 | − | const name = me.display_username ?? me.username; | |
| 17 | − | const oauth = client.credentials.kind === "oauth"; | |
| 18 | − | ||
| 19 | − | return ( | |
| 20 | − | <Screen scroll style={{ paddingHorizontal: 0 }}> | |
| 21 | − | <Stack.Screen options={{ title: "You" }} /> | |
| 22 | − | <View style={{ flexDirection: "row", gap: space.md, alignItems: "center", paddingHorizontal: space.lg }}> | |
| 23 | − | <Avatar name={name} uri={me.avatar ? `${server.site}/avatars/${me.avatar}` : null} size={48} /> | |
| 24 | − | <View style={{ flex: 1 }}> | |
| 25 | − | <Text size="heading">{name}</Text> | |
| 26 | − | <Text size="small" tone="muted"> | |
| 27 | − | {isHosted(server) ? "g1t.sh" : new URL(server.site).host} | |
| 28 | − | </Text> | |
| 29 | − | </View> | |
| 30 | − | </View> | |
| 31 | − | ||
| 32 | − | <View> | |
| 33 | − | <SectionHeader title="Workspaces" /> | |
| 34 | − | {(me.workspaces ?? []).map((one) => ( | |
| 35 | − | <Row | |
| 36 | − | key={one.slug} | |
| 37 | − | leading={<Avatar name={one.name ?? one.slug} uri={one.avatar ? `${server.site}/avatars/${one.avatar}` : null} />} | |
| 38 | − | title={one.name ?? one.slug} | |
| 39 | − | subtitle={`${one.slug} · ${one.role}`} | |
| 40 | − | trailing={one.slug === workspace?.slug ? <Check size={18} color={colors.accent} /> : null} | |
| 41 | − | chevron={false} | |
| 42 | − | onPress={() => selectWorkspace(one.slug)} | |
| 43 | − | /> | |
| 44 | − | ))} | |
| 45 | − | </View> | |
| 46 | − | ||
| 47 | − | <View> | |
| 48 | − | <SectionHeader title="Signed in" /> | |
| 49 | − | <Row | |
| 50 | − | title={oauth ? "With g1t" : "With an access token"} | |
| 51 | − | subtitle={ | |
| 52 | − | oauth | |
| 53 | − | ? "End or narrow the app's access in Settings → Connected applications." | |
| 54 | − | : "The token you pasted. Delete it in Settings → Access tokens to end it." | |
| 55 | − | } | |
| 56 | − | trailing={<ExternalLink size={16} color={colors.faint} />} | |
| 57 | − | chevron={false} | |
| 58 | − | onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, oauth ? "/settings/applications" : "/settings/tokens"))} | |
| 59 | − | /> | |
| 60 | − | </View> | |
| 61 | − | ||
| 62 | − | <View style={{ paddingHorizontal: space.lg, gap: space.md }}> | |
| 63 | − | <Button title="Sign out" variant="danger" icon={LogOut} onPress={signOut} /> | |
| 64 | − | <Text size="small" tone="faint" style={{ textAlign: "center" }}> | |
| 65 | − | g1t {Constants.expoConfig?.version ?? ""} | |
| 66 | − | </Text> | |
| 67 | − | </View> | |
| 68 | − | </Screen> | |
| 69 | − | ); | |
| 70 | − | } |
| 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 { useState } from "react"; | |
| 4 | + | import { Pressable, StyleSheet, TextInput, View } from "react-native"; | |
| 5 | + | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 6 | + | ||
| 7 | + | import { radius, space, useColors } from "@/theme"; | |
| 8 | + | ||
| 9 | + | /** What was typed and not sent, per conversation, while the app runs. */ | |
| 10 | + | const drafts = new Map<string, string>(); | |
| 11 | + | ||
| 12 | + | /** | |
| 13 | + | * The box at the bottom of a conversation. Enter makes a new line, as on | |
| 14 | + | * a phone; the arrow sends. Typing tells the others (at most every 3 s). | |
| 15 | + | */ | |
| 16 | + | export function Composer({ draftKey, placeholder, onSend, onTyping }: { draftKey: string; placeholder: string; onSend: (body: string) => void; onTyping: () => void }) { | |
| 17 | + | const colors = useColors(); | |
| 18 | + | const insets = useSafeAreaInsets(); | |
| 19 | + | const [text, setText] = useState(() => drafts.get(draftKey) ?? ""); | |
| 20 | + | const ready = text.trim().length > 0; | |
| 21 | + | ||
| 22 | + | const send = () => { | |
| 23 | + | const body = text.trim(); | |
| 24 | + | if (!body) return; | |
| 25 | + | void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined); | |
| 26 | + | onSend(body); | |
| 27 | + | setText(""); | |
| 28 | + | drafts.delete(draftKey); | |
| 29 | + | }; | |
| 30 | + | ||
| 31 | + | return ( | |
| 32 | + | <View style={[styles.bar, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: Math.max(insets.bottom, space.sm) }]}> | |
| 33 | + | <TextInput | |
| 34 | + | value={text} | |
| 35 | + | onChangeText={(next) => { | |
| 36 | + | setText(next); | |
| 37 | + | drafts.set(draftKey, next); | |
| 38 | + | if (next.length > 0) onTyping(); | |
| 39 | + | }} | |
| 40 | + | placeholder={placeholder} | |
| 41 | + | placeholderTextColor={colors.faint} | |
| 42 | + | multiline | |
| 43 | + | style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]} | |
| 44 | + | /> | |
| 45 | + | <Pressable | |
| 46 | + | accessibilityRole="button" | |
| 47 | + | accessibilityLabel="Send" | |
| 48 | + | onPress={send} | |
| 49 | + | disabled={!ready} | |
| 50 | + | style={[styles.send, { backgroundColor: ready ? colors.accent : colors.raised }]} | |
| 51 | + | > | |
| 52 | + | <ArrowUp size={20} color={ready ? colors.onAccent : colors.faint} strokeWidth={2.5} /> | |
| 53 | + | </Pressable> | |
| 54 | + | </View> | |
| 55 | + | ); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | const styles = StyleSheet.create({ | |
| 59 | + | bar: { flexDirection: "row", alignItems: "flex-end", gap: space.sm, paddingHorizontal: space.md, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth }, | |
| 60 | + | input: { flex: 1, minHeight: 40, maxHeight: 160, borderRadius: radius.lg, borderWidth: 1, paddingHorizontal: space.md, paddingTop: 10, paddingBottom: 10, fontSize: 15 }, | |
| 61 | + | send: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center" }, | |
| 62 | + | }); |
| 1 | + | import type { CardAction, ChatMessage } from "@g1t/contracts"; | |
| 2 | + | import { useQueryClient } from "@tanstack/react-query"; | |
| 3 | + | import * as Clipboard from "expo-clipboard"; | |
| 4 | + | import { router, useFocusEffect } from "expo-router"; | |
| 5 | + | import { ArrowDown, Copy, MessageSquareReply, Pencil, Trash2 } from "lucide-react-native"; | |
| 6 | + | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 7 | + | import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, View } from "react-native"; | |
| 8 | + | ||
| 9 | + | import { Composer } from "@/components/chat/composer"; | |
| 10 | + | import { Message, runsOn } from "@/components/chat/message"; | |
| 11 | + | import { PromptSheet, Sheet, SheetAction } from "@/components/chat/sheet"; | |
| 12 | + | import { ErrorView, Loading, Text } from "@/components/ui"; | |
| 13 | + | import { type Shown, markRead, useChannelLive, useChatActions, useMessages, useSend } from "@/lib/chat"; | |
| 14 | + | import { useSignedIn } from "@/lib/session"; | |
| 15 | + | import { viewing } from "@/lib/viewing"; | |
| 16 | + | import { radius, space, useColors } from "@/theme"; | |
| 17 | + | ||
| 18 | + | const QUICK = ["👍", "❤️", "😂", "🎉", "👀", "✅"]; | |
| 19 | + | ||
| 20 | + | type Row = { kind: "message"; message: Shown; head: boolean } | { kind: "day"; key: string; label: string }; | |
| 21 | + | ||
| 22 | + | /** | |
| 23 | + | * A conversation, or one thread of it: messages newest at the bottom, live | |
| 24 | + | * while open, read as they arrive, and a box to write in. Long-press a | |
| 25 | + | * message to react, reply in a thread, copy, or change or delete your own. | |
| 26 | + | */ | |
| 27 | + | export function Conversation({ channelId, thread, title }: { channelId: string; thread: string | null; title: string }) { | |
| 28 | + | const colors = useColors(); | |
| 29 | + | const queries = useQueryClient(); | |
| 30 | + | const { client, workspace, me } = useSignedIn(); | |
| 31 | + | const slug = workspace?.slug ?? ""; | |
| 32 | + | const messages = useMessages(channelId, thread); | |
| 33 | + | const live = useChannelLive(channelId); | |
| 34 | + | const send = useSend(channelId, thread); | |
| 35 | + | const actions = useChatActions(channelId); | |
| 36 | + | const [picked, setPicked] = useState<Shown | null>(null); | |
| 37 | + | const [editing, setEditing] = useState<Shown | null>(null); | |
| 38 | + | const [scrolledUp, setScrolledUp] = useState(false); | |
| 39 | + | const list = useRef<FlatList<Row>>(null); | |
| 40 | + | const focused = useRef(false); | |
| 41 | + | ||
| 42 | + | // While on screen, this conversation's banners and pushes are not shown. | |
| 43 | + | useFocusEffect( | |
| 44 | + | useCallback(() => { | |
| 45 | + | focused.current = true; | |
| 46 | + | viewing.channelId = channelId; | |
| 47 | + | viewing.threadRoot = thread; | |
| 48 | + | return () => { | |
| 49 | + | focused.current = false; | |
| 50 | + | if (viewing.channelId === channelId) { | |
| 51 | + | viewing.channelId = null; | |
| 52 | + | viewing.threadRoot = null; | |
| 53 | + | } | |
| 54 | + | }; | |
| 55 | + | }, [channelId, thread]), | |
| 56 | + | ); | |
| 57 | + | ||
| 58 | + | const all = useMemo(() => { | |
| 59 | + | const pages = messages.data?.pages ?? []; | |
| 60 | + | // Oldest first: the last page read is the oldest. | |
| 61 | + | const ordered = [...pages].reverse().flatMap((page) => page.messages as Shown[]); | |
| 62 | + | // A thread's first page carries the message it is under. | |
| 63 | + | const root = thread ? (pages[0]?.root ?? null) : null; | |
| 64 | + | const shown = ordered.filter((message) => !message.deleted_at && message.id !== root?.id); | |
| 65 | + | return root ? [root as Shown, ...shown] : shown; | |
| 66 | + | }, [messages.data, thread]); | |
| 67 | + | ||
| 68 | + | // Rows newest first, for a list drawn from the bottom up, with the day between days. | |
| 69 | + | const rows = useMemo(() => { | |
| 70 | + | const out: Row[] = []; | |
| 71 | + | let day = ""; | |
| 72 | + | all.forEach((message, index) => { | |
| 73 | + | const date = new Date(message.created_at); | |
| 74 | + | const label = dayLabel(date); | |
| 75 | + | if (label !== day) { | |
| 76 | + | out.push({ kind: "day", key: `day-${label}-${index}`, label }); | |
| 77 | + | day = label; | |
| 78 | + | } | |
| 79 | + | out.push({ kind: "message", message, head: label !== dayLabel(new Date(all[index - 1]?.created_at ?? 0)) || !runsOn(all[index - 1], message) }); | |
| 80 | + | }); | |
| 81 | + | return out.reverse(); | |
| 82 | + | }, [all]); | |
| 83 | + | ||
| 84 | + | // Read up to the newest while it is on screen. | |
| 85 | + | const newest = all.filter((message) => !message.local).at(-1)?.id; | |
| 86 | + | useEffect(() => { | |
| 87 | + | if (slug && newest && focused.current && !thread) void markRead(client, queries, slug, channelId, newest); | |
| 88 | + | }, [client, queries, slug, channelId, newest, thread]); | |
| 89 | + | ||
| 90 | + | const onCardAction = useCallback( | |
| 91 | + | async (message: ChatMessage, action: CardAction, input?: string | null) => { | |
| 92 | + | const answer = await actions.cardAction.mutateAsync({ message, action: action.id, input }); | |
| 93 | + | if (!answer.ok) throw new Error(answer.message ?? "That didn't work."); | |
| 94 | + | return answer.message; | |
| 95 | + | }, | |
| 96 | + | [actions.cardAction], | |
| 97 | + | ); | |
| 98 | + | ||
| 99 | + | const onReact = useCallback((message: ChatMessage, emoji: string, add: boolean) => actions.react.mutate({ message, emoji, add }), [actions.react]); | |
| 100 | + | const onRetry = useCallback((message: Shown) => send(message.body, message), [send]); | |
| 101 | + | ||
| 102 | + | const mine = picked?.author.kind === "user" && picked.author.id === me.id; | |
| 103 | + | const typingLine = typingText(live.typing.map((member) => member.display_name)); | |
| 104 | + | ||
| 105 | + | return ( | |
| 106 | + | <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined} keyboardVerticalOffset={Platform.OS === "ios" ? 96 : 0}> | |
| 107 | + | <View style={{ flex: 1 }}> | |
| 108 | + | {messages.isPending ? ( | |
| 109 | + | <Loading /> | |
| 110 | + | ) : messages.error ? ( | |
| 111 | + | <ErrorView error={messages.error} retry={() => messages.refetch()} /> | |
| 112 | + | ) : ( | |
| 113 | + | <FlatList | |
| 114 | + | ref={list} | |
| 115 | + | inverted | |
| 116 | + | data={rows} | |
| 117 | + | keyExtractor={(row) => (row.kind === "day" ? row.key : row.message.id)} | |
| 118 | + | renderItem={({ item }) => | |
| 119 | + | item.kind === "day" ? ( | |
| 120 | + | <View style={styles.day}> | |
| 121 | + | <View style={[styles.dayLine, { backgroundColor: colors.line }]} /> | |
| 122 | + | <Text size="small" tone="faint" style={{ fontWeight: "600" }}> | |
| 123 | + | {item.label} | |
| 124 | + | </Text> | |
| 125 | + | <View style={[styles.dayLine, { backgroundColor: colors.line }]} /> | |
| 126 | + | </View> | |
| 127 | + | ) : ( | |
| 128 | + | <Message | |
| 129 | + | message={item.message} | |
| 130 | + | head={item.head} | |
| 131 | + | inThread={!!thread} | |
| 132 | + | onLongPress={setPicked} | |
| 133 | + | onReact={onReact} | |
| 134 | + | onRetry={onRetry} | |
| 135 | + | onCardAction={onCardAction} | |
| 136 | + | /> | |
| 137 | + | ) | |
| 138 | + | } | |
| 139 | + | onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()} | |
| 140 | + | onEndReachedThreshold={0.6} | |
| 141 | + | onScroll={(event) => setScrolledUp(event.nativeEvent.contentOffset.y > 600)} | |
| 142 | + | scrollEventThrottle={100} | |
| 143 | + | ListHeaderComponent={<View style={{ height: space.sm }} />} | |
| 144 | + | ListFooterComponent={ | |
| 145 | + | messages.isFetchingNextPage ? ( | |
| 146 | + | <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} /> | |
| 147 | + | ) : !messages.hasNextPage ? ( | |
| 148 | + | <Text size="small" tone="faint" style={{ textAlign: "center", padding: space.xl }}> | |
| 149 | + | {thread ? "The start of this thread." : `The start of ${title}.`} | |
| 150 | + | </Text> | |
| 151 | + | ) : null | |
| 152 | + | } | |
| 153 | + | keyboardDismissMode="interactive" | |
| 154 | + | keyboardShouldPersistTaps="handled" | |
| 155 | + | maintainVisibleContentPosition={{ minIndexForVisible: 1 }} | |
| 156 | + | /> | |
| 157 | + | )} | |
| 158 | + | {scrolledUp ? ( | |
| 159 | + | <Pressable | |
| 160 | + | accessibilityLabel="Go to newest" | |
| 161 | + | onPress={() => list.current?.scrollToOffset({ offset: 0, animated: true })} | |
| 162 | + | style={[styles.jump, { backgroundColor: colors.surface, borderColor: colors.lineStrong }]} | |
| 163 | + | > | |
| 164 | + | <ArrowDown size={18} color={colors.fg} /> | |
| 165 | + | </Pressable> | |
| 166 | + | ) : null} | |
| 167 | + | </View> | |
| 168 | + | <View style={styles.typing}> | |
| 169 | + | <Text size="small" tone="faint" numberOfLines={1}> | |
| 170 | + | {typingLine} | |
| 171 | + | </Text> | |
| 172 | + | {live.status !== "live" ? ( | |
| 173 | + | <Text size="small" tone="faint"> | |
| 174 | + | {live.status === "connecting" ? "Connecting…" : "Offline, trying again"} | |
| 175 | + | </Text> | |
| 176 | + | ) : null} | |
| 177 | + | </View> | |
| 178 | + | <Composer draftKey={`${channelId}:${thread ?? ""}`} placeholder={thread ? "Reply in thread" : `Message ${title}`} onSend={(body) => send(body)} onTyping={live.sayTyping} /> | |
| 179 | + | ||
| 180 | + | <Sheet visible={!!picked} onClose={() => setPicked(null)}> | |
| 181 | + | <View style={styles.quick}> | |
| 182 | + | {QUICK.map((emoji) => { | |
| 183 | + | const had = picked?.reactions?.find((one) => one.emoji === emoji)?.me ?? false; | |
| 184 | + | return ( | |
| 185 | + | <Pressable | |
| 186 | + | key={emoji} | |
| 187 | + | onPress={() => { | |
| 188 | + | if (picked) onReact(picked, emoji, !had); | |
| 189 | + | setPicked(null); | |
| 190 | + | }} | |
| 191 | + | style={[styles.quickOne, { backgroundColor: had ? colors.accentLow : colors.raised }]} | |
| 192 | + | > | |
| 193 | + | <Text style={{ fontSize: 24, lineHeight: 30 }}>{emoji}</Text> | |
| 194 | + | </Pressable> | |
| 195 | + | ); | |
| 196 | + | })} | |
| 197 | + | </View> | |
| 198 | + | {!thread ? ( | |
| 199 | + | <SheetAction | |
| 200 | + | icon={MessageSquareReply} | |
| 201 | + | label="Reply in thread" | |
| 202 | + | onPress={() => { | |
| 203 | + | const message = picked; | |
| 204 | + | setPicked(null); | |
| 205 | + | if (message) router.push({ pathname: "/chat/thread", params: { channel: channelId, root: message.id } }); | |
| 206 | + | }} | |
| 207 | + | /> | |
| 208 | + | ) : null} | |
| 209 | + | <SheetAction | |
| 210 | + | icon={Copy} | |
| 211 | + | label="Copy text" | |
| 212 | + | onPress={() => { | |
| 213 | + | if (picked?.body) void Clipboard.setStringAsync(picked.body); | |
| 214 | + | setPicked(null); | |
| 215 | + | }} | |
| 216 | + | /> | |
| 217 | + | {mine && picked?.kind === "text" ? ( | |
| 218 | + | <SheetAction | |
| 219 | + | icon={Pencil} | |
| 220 | + | label="Edit" | |
| 221 | + | onPress={() => { | |
| 222 | + | setEditing(picked); | |
| 223 | + | setPicked(null); | |
| 224 | + | }} | |
| 225 | + | /> | |
| 226 | + | ) : null} | |
| 227 | + | {mine ? ( | |
| 228 | + | <SheetAction | |
| 229 | + | icon={Trash2} | |
| 230 | + | label="Delete" | |
| 231 | + | danger | |
| 232 | + | onPress={() => { | |
| 233 | + | const message = picked; | |
| 234 | + | setPicked(null); | |
| 235 | + | if (!message) return; | |
| 236 | + | Alert.alert("Delete this message?", "It is gone for everyone in the conversation.", [ | |
| 237 | + | { text: "Cancel", style: "cancel" }, | |
| 238 | + | { text: "Delete", style: "destructive", onPress: () => actions.remove.mutate(message) }, | |
| 239 | + | ]); | |
| 240 | + | }} | |
| 241 | + | /> | |
| 242 | + | ) : null} | |
| 243 | + | </Sheet> | |
| 244 | + | ||
| 245 | + | <PromptSheet | |
| 246 | + | visible={!!editing} | |
| 247 | + | title="Edit message" | |
| 248 | + | initial={editing?.body ?? ""} | |
| 249 | + | multiline | |
| 250 | + | confirm="Save" | |
| 251 | + | onCancel={() => setEditing(null)} | |
| 252 | + | onDone={(body) => { | |
| 253 | + | if (editing && body !== editing.body) actions.edit.mutate({ message: editing, body }); | |
| 254 | + | setEditing(null); | |
| 255 | + | }} | |
| 256 | + | /> | |
| 257 | + | </KeyboardAvoidingView> | |
| 258 | + | ); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | function dayLabel(date: Date): string { | |
| 262 | + | const today = new Date(); | |
| 263 | + | const yesterday = new Date(today.getTime() - 86_400_000); | |
| 264 | + | if (date.toDateString() === today.toDateString()) return "Today"; | |
| 265 | + | if (date.toDateString() === yesterday.toDateString()) return "Yesterday"; | |
| 266 | + | return date.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", ...(date.getFullYear() !== today.getFullYear() ? { year: "numeric" } : {}) }); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | function typingText(names: string[]): string { | |
| 270 | + | if (names.length === 0) return ""; | |
| 271 | + | if (names.length === 1) return `${names[0]} is typing…`; | |
| 272 | + | if (names.length === 2) return `${names[0]} and ${names[1]} are typing…`; | |
| 273 | + | return "Several people are typing…"; | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | const styles = StyleSheet.create({ | |
| 277 | + | day: { flexDirection: "row", alignItems: "center", gap: space.sm, paddingHorizontal: space.lg, paddingVertical: space.md }, | |
| 278 | + | dayLine: { flex: 1, height: StyleSheet.hairlineWidth }, | |
| 279 | + | jump: { position: "absolute", right: space.lg, bottom: space.md, width: 40, height: 40, borderRadius: 20, borderWidth: 1, alignItems: "center", justifyContent: "center" }, | |
| 280 | + | typing: { flexDirection: "row", justifyContent: "space-between", paddingHorizontal: space.lg, minHeight: 18 }, | |
| 281 | + | quick: { flexDirection: "row", justifyContent: "space-around", paddingHorizontal: space.md, paddingBottom: space.sm }, | |
| 282 | + | quickOne: { width: 48, height: 48, borderRadius: radius.full, alignItems: "center", justifyContent: "center" }, | |
| 283 | + | }); |
| 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 { 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 type { ChatLiveEvent, ChatMessage, ChatSidebar, MessagePage } from "@g1t/contracts"; | |
| 2 | − | import { type InfiniteData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | − | import { useEffect, useState } from "react"; | |
| 1 | + | import type { Channel, ChatLiveEvent, ChatMessage, ChatSidebar, MemberProfile, MessagePage } from "@g1t/contracts"; | |
| 2 | + | import { type InfiniteData, type QueryClient, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | + | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Client } from "./client"; | |
| 6 | − | import { socketUrl } from "./server"; | |
| 6 | + | import { openSocket } from "./live"; | |
| 7 | 7 | import { useSignedIn } from "./session"; | |
| 8 | 8 | ||
| 9 | 9 | /** | |
| ⋯ | |||
| 11 | 11 | * (apps/web/app/routes/workspace/chat/api.ts): | |
| 12 | 12 | * | |
| 13 | 13 | * - `GET /<workspace>/-/chat/api`: the sidebar. | |
| 14 | − | * - `GET /<workspace>/-/chat/api?channel=<id>&before=<id>`: a page of messages. | |
| 15 | − | * - `POST /<workspace>/-/chat/api` with `{ intent, … }`: post, read, react. | |
| 16 | − | * - `wss://<site>/<workspace>/-/chat/live?channel=<id>`: what happens, live. | |
| 17 | − | * A socket cannot carry a header, so it asks `GET /-/live/ticket` for a | |
| 18 | − | * ticket first (apps/web/app/lib/socket-ticket.ts). | |
| 14 | + | * - `GET /<workspace>/-/chat/api?channel=<id>[&thread=<id>]&before=<id>`: a page of messages. | |
| 15 | + | * - `POST /<workspace>/-/chat/api` with `{ intent, … }`: everything sent. | |
| 16 | + | * - `wss://<site>/<workspace>/-/chat/live?channel=<id>`: what happens in a | |
| 17 | + | * conversation, live, and the one thing a client says there: typing. | |
| 18 | + | * | |
| 19 | + | * The app signs in with `chat:read` and `chat:write`, which the site takes | |
| 20 | + | * on these routes alone. | |
| 19 | 21 | */ | |
| 20 | 22 | ||
| 21 | 23 | const chatPath = (workspace: string) => `/${encodeURIComponent(workspace)}/-/chat/api`; | |
| 22 | 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 | + | ||
| 23 | 44 | export function useChatSidebar() { | |
| 24 | 45 | const { client, workspace } = useSignedIn(); | |
| 25 | 46 | const slug = workspace?.slug ?? ""; | |
| 26 | 47 | return useQuery({ | |
| 27 | − | queryKey: ["chat", slug, "sidebar"], | |
| 48 | + | queryKey: chatKeys.sidebar(slug), | |
| 28 | 49 | queryFn: () => client.site<ChatSidebar>(chatPath(slug)), | |
| 29 | 50 | enabled: !!slug, | |
| 30 | 51 | }); | |
| 31 | 52 | } | |
| 32 | 53 | ||
| 33 | − | export function useMessages(channelId: string, thread?: string | null) { | |
| 54 | + | /** The workspace's channels, to find and join (`GET …/chat/api?browse=1`). */ | |
| 55 | + | export function useBrowseChannels() { | |
| 34 | 56 | const { client, workspace } = useSignedIn(); | |
| 35 | 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 ?? ""; | |
| 36 | 68 | return useInfiniteQuery({ | |
| 37 | − | queryKey: ["chat", slug, "messages", channelId, thread ?? null], | |
| 69 | + | queryKey: chatKeys.messages(slug, channelId, thread), | |
| 38 | 70 | initialPageParam: null as string | null, | |
| 39 | 71 | queryFn: ({ pageParam }) => { | |
| 40 | − | const query = new URLSearchParams({ channel: channelId, limit: "60" }); | |
| 72 | + | const query = new URLSearchParams({ channel: channelId, limit: "50" }); | |
| 41 | 73 | if (thread) query.set("thread", thread); | |
| 42 | 74 | if (pageParam) query.set("before", pageParam); | |
| 43 | 75 | return client.site<MessagePage>(`${chatPath(slug)}?${query}`); | |
| ⋯ | |||
| 47 | 79 | }); | |
| 48 | 80 | } | |
| 49 | 81 | ||
| 50 | − | /** Sends a message; it appears through the live socket, or on the next read. */ | |
| 51 | − | export function usePostMessage(channelId: string, thread?: string | null) { | |
| 82 | + | // ── The cache, as messages arrive ─────────────────────────────────────────── | |
| 83 | + | ||
| 84 | + | function updatePages(queries: QueryClient, key: readonly unknown[], change: (messages: Shown[], index: number) => Shown[]) { | |
| 85 | + | queries.setQueryData<Pages>(key, (data) => (data ? { ...data, pages: data.pages.map((page, index) => ({ ...page, messages: change(page.messages, index) })) } : data)); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /** A message, put where it belongs: replacing itself, or its local copy, or added as the newest. */ | |
| 89 | + | export function upsertMessage(queries: QueryClient, workspace: string, message: Shown, localId?: string) { | |
| 90 | + | const key = chatKeys.messages(workspace, message.channel_id, message.thread_root ?? null); | |
| 91 | + | const data = queries.getQueryData<Pages>(key); | |
| 92 | + | const had = !!data?.pages.some((page) => page.messages.some((one) => one.id === message.id)); | |
| 93 | + | const hadLocal = !!localId && !!data?.pages.some((page) => page.messages.some((one) => one.id === localId)); | |
| 94 | + | if (data) { | |
| 95 | + | if (had || hadLocal) { | |
| 96 | + | updatePages(queries, key, (messages) => { | |
| 97 | + | const replaced = messages.map((one) => (one.id === message.id || (localId && one.id === localId) ? message : one)); | |
| 98 | + | // A local copy and the real one may both have arrived: keep one. | |
| 99 | + | return replaced.filter((one, index) => replaced.findIndex((other) => other.id === one.id) === index); | |
| 100 | + | }); | |
| 101 | + | } else { | |
| 102 | + | updatePages(queries, key, (messages, index) => (index === 0 ? [...messages, message] : messages)); | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | // A reply new to the app also counts on its root, in the conversation. | |
| 106 | + | if (message.thread_root && !message.local && !had) { | |
| 107 | + | updatePages(queries, chatKeys.messages(workspace, message.channel_id, null), (messages) => | |
| 108 | + | messages.map((one) => (one.id === message.thread_root ? { ...one, reply_count: one.reply_count + 1, last_reply_at: message.created_at } : one)), | |
| 109 | + | ); | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | function removeMessage(queries: QueryClient, workspace: string, channelId: string, id: string) { | |
| 114 | + | for (const [key] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", channelId] })) { | |
| 115 | + | updatePages(queries, key, (messages) => messages.filter((one) => one.id !== id)); | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | function patchMessage(queries: QueryClient, workspace: string, channelId: string, id: string, change: (message: Shown) => Shown) { | |
| 120 | + | for (const [key] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", channelId] })) { | |
| 121 | + | updatePages(queries, key, (messages) => messages.map((one) => (one.id === id ? change(one) : one))); | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /** A reaction added or taken away, by the viewer (`mine`) or someone else, on a message in the cache. */ | |
| 126 | + | function applyReaction(queries: QueryClient, workspace: string, channelId: string, messageId: string, emoji: string, member: MemberProfile | null, added: boolean, mine: boolean) { | |
| 127 | + | patchMessage(queries, workspace, channelId, messageId, (message) => { | |
| 128 | + | const reactions = [...(message.reactions ?? [])]; | |
| 129 | + | const at = reactions.findIndex((one) => one.emoji === emoji); | |
| 130 | + | if (at === -1) { | |
| 131 | + | if (!added) return message; | |
| 132 | + | reactions.push({ emoji, count: 1, me: mine, by: member ? [member] : [] }); | |
| 133 | + | } else { | |
| 134 | + | const one = reactions[at]; | |
| 135 | + | if (mine && one.me === added) return message; | |
| 136 | + | const count = one.count + (added ? 1 : -1); | |
| 137 | + | if (count <= 0) reactions.splice(at, 1); | |
| 138 | + | else | |
| 139 | + | reactions[at] = { | |
| 140 | + | ...one, | |
| 141 | + | count, | |
| 142 | + | me: mine ? added : one.me, | |
| 143 | + | by: member ? (added ? [...one.by, member] : one.by.filter((m) => m.id !== member.id)) : one.by, | |
| 144 | + | }; | |
| 145 | + | } | |
| 146 | + | return { ...message, reactions }; | |
| 147 | + | }); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /** | |
| 151 | + | * Live events carry every reaction's `me` as false, since the whole room | |
| 152 | + | * gets them: keep the viewer's own from the copy the app has. | |
| 153 | + | */ | |
| 154 | + | function keepMine(queries: QueryClient, workspace: string, message: ChatMessage): ChatMessage { | |
| 155 | + | for (const [, data] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", message.channel_id] })) { | |
| 156 | + | const had = data?.pages.flatMap((page) => page.messages).find((one) => one.id === message.id); | |
| 157 | + | if (had) { | |
| 158 | + | const mine = new Set((had.reactions ?? []).filter((one) => one.me).map((one) => one.emoji)); | |
| 159 | + | return { ...message, reactions: (message.reactions ?? []).map((one) => ({ ...one, me: mine.has(one.emoji) })) }; | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | return message; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // ── Sending ──────────────────────────────────────────────────────────────── | |
| 166 | + | ||
| 167 | + | let localCounter = 0; | |
| 168 | + | ||
| 169 | + | /** | |
| 170 | + | * Sends a message. It shows at once, marked as sending, and becomes the | |
| 171 | + | * real one when the site answers (or when the live socket brings it, | |
| 172 | + | * whichever is first); a failed one stays, marked, to send again. | |
| 173 | + | */ | |
| 174 | + | export function useSend(channelId: string, thread: string | null = null) { | |
| 175 | + | const { client, workspace, me } = useSignedIn(); | |
| 176 | + | const queries = useQueryClient(); | |
| 177 | + | const slug = workspace?.slug ?? ""; | |
| 178 | + | ||
| 179 | + | return useCallback( | |
| 180 | + | (body: string, retryOf?: Shown) => { | |
| 181 | + | const localId = retryOf?.id ?? `local-${Date.now()}-${localCounter++}`; | |
| 182 | + | const local: Shown = { | |
| 183 | + | id: localId, | |
| 184 | + | channel_id: channelId, | |
| 185 | + | author: { | |
| 186 | + | kind: "user", | |
| 187 | + | id: me.id, | |
| 188 | + | name: me.username, | |
| 189 | + | display_username: me.display_username ?? null, | |
| 190 | + | display_name: me.display_username ?? me.username, | |
| 191 | + | avatar: me.avatar ?? null, | |
| 192 | + | role: null, | |
| 193 | + | } as MemberProfile, | |
| 194 | + | kind: "text", | |
| 195 | + | body, | |
| 196 | + | card: null, | |
| 197 | + | thread_root: thread, | |
| 198 | + | reply_count: 0, | |
| 199 | + | last_reply_at: null, | |
| 200 | + | created_at: new Date().toISOString(), | |
| 201 | + | edited_at: null, | |
| 202 | + | deleted_at: null, | |
| 203 | + | reactions: [], | |
| 204 | + | local: "sending", | |
| 205 | + | }; | |
| 206 | + | upsertMessage(queries, slug, local, localId); | |
| 207 | + | sendIntent<ChatMessage>(client, slug, "post", { channel_id: channelId, body, thread_root: thread }) | |
| 208 | + | .then((message) => { | |
| 209 | + | upsertMessage(queries, slug, message, localId); | |
| 210 | + | void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }); | |
| 211 | + | }) | |
| 212 | + | .catch(() => upsertMessage(queries, slug, { ...local, local: "failed" }, localId)); | |
| 213 | + | }, | |
| 214 | + | [client, queries, slug, channelId, thread, me], | |
| 215 | + | ); | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /** Everything else a person does to messages and conversations. */ | |
| 219 | + | export function useChatActions(channelId: string) { | |
| 52 | 220 | const { client, workspace } = useSignedIn(); | |
| 53 | 221 | const queries = useQueryClient(); | |
| 54 | 222 | const slug = workspace?.slug ?? ""; | |
| 55 | − | return useMutation({ | |
| 56 | − | mutationFn: (body: string) => | |
| 57 | − | client.site<ChatMessage>(chatPath(slug), { | |
| 58 | − | method: "POST", | |
| 59 | − | body: JSON.stringify({ intent: "post", channel_id: channelId, body, thread_root: thread ?? null }), | |
| 60 | − | }), | |
| 61 | − | onSuccess: (message) => addMessage(queries, slug, message), | |
| 223 | + | ||
| 224 | + | const react = useMutation({ | |
| 225 | + | mutationFn: ({ message, emoji, add }: { message: ChatMessage; emoji: string; add: boolean }) => | |
| 226 | + | sendIntent(client, slug, add ? "react" : "unreact", { channel_id: channelId, message_id: message.id, emoji }), | |
| 227 | + | onMutate: ({ message, emoji, add }) => applyReaction(queries, slug, channelId, message.id, emoji, null, add, true), | |
| 228 | + | onError: (_error, { message, emoji, add }) => applyReaction(queries, slug, channelId, message.id, emoji, null, !add, true), | |
| 62 | 229 | }); | |
| 63 | − | } | |
| 64 | 230 | ||
| 65 | − | /** Marks a channel read up to a message. */ | |
| 66 | − | export function markRead(client: Client, workspace: string, channelId: string, messageId: string) { | |
| 67 | − | return client | |
| 68 | − | .site(chatPath(workspace), { method: "POST", body: JSON.stringify({ intent: "read", channel_id: channelId, id: messageId }) }) | |
| 69 | − | .catch(() => undefined); | |
| 70 | − | } | |
| 231 | + | const edit = useMutation({ | |
| 232 | + | mutationFn: ({ message, body }: { message: ChatMessage; body: string }) => sendIntent<ChatMessage>(client, slug, "edit", { channel_id: channelId, id: message.id, body }), | |
| 233 | + | onSuccess: (message) => upsertMessage(queries, slug, message), | |
| 234 | + | }); | |
| 71 | 235 | ||
| 72 | − | type Pages = InfiniteData<MessagePage, string | null>; | |
| 236 | + | const remove = useMutation({ | |
| 237 | + | mutationFn: (message: ChatMessage) => sendIntent(client, slug, "remove", { channel_id: channelId, id: message.id }), | |
| 238 | + | onMutate: (message) => removeMessage(queries, slug, channelId, message.id), | |
| 239 | + | onError: () => queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }), | |
| 240 | + | }); | |
| 73 | 241 | ||
| 74 | − | /** Puts a message into the newest page of its channel's cache, once. */ | |
| 75 | − | function addMessage(queries: ReturnType<typeof useQueryClient>, workspace: string, message: ChatMessage) { | |
| 76 | − | queries.setQueryData<Pages>(["chat", workspace, "messages", message.channel_id, message.thread_root ?? null], (data) => { | |
| 77 | − | if (!data) return data; | |
| 78 | − | if (data.pages.some((page) => page.messages.some((one) => one.id === message.id))) return data; | |
| 79 | − | const [newest, ...rest] = data.pages; | |
| 80 | − | return { ...data, pages: [{ ...newest, messages: [...newest.messages, message] }, ...rest] }; | |
| 242 | + | /** Presses a button on an agent's card: approve, stop, file. */ | |
| 243 | + | const cardAction = useMutation({ | |
| 244 | + | mutationFn: ({ message, action, input }: { message: ChatMessage; action: string; input?: string | null }) => | |
| 245 | + | sendIntent<{ ok: boolean; message: string | null }>(client, slug, "card_action", { channel_id: channelId, message_id: message.id, action_id: action, input: input ?? null }), | |
| 81 | 246 | }); | |
| 82 | − | void queries.invalidateQueries({ queryKey: ["chat", workspace, "sidebar"] }); | |
| 247 | + | ||
| 248 | + | const preferences = useMutation({ | |
| 249 | + | mutationFn: (change: { starred?: boolean; muted?: boolean }) => sendIntent(client, slug, "preferences", { channel_id: channelId, ...change }), | |
| 250 | + | onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| 251 | + | }); | |
| 252 | + | ||
| 253 | + | const leave = useMutation({ | |
| 254 | + | mutationFn: () => sendIntent(client, slug, "leave", { channel_id: channelId }), | |
| 255 | + | onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| 256 | + | }); | |
| 257 | + | ||
| 258 | + | return { react, edit, remove, cardAction, preferences, leave }; | |
| 83 | 259 | } | |
| 84 | 260 | ||
| 85 | − | function replaceMessage(queries: ReturnType<typeof useQueryClient>, workspace: string, message: ChatMessage) { | |
| 86 | − | queries.setQueryData<Pages>(["chat", workspace, "messages", message.channel_id, message.thread_root ?? null], (data) => | |
| 87 | − | data | |
| 88 | − | ? { ...data, pages: data.pages.map((page) => ({ ...page, messages: page.messages.map((one) => (one.id === message.id ? message : one)) })) } | |
| 89 | − | : data, | |
| 261 | + | /** Marks a conversation read up to a message; the sidebar's counts follow. */ | |
| 262 | + | export function markRead(client: Client, queries: QueryClient, workspace: string, channelId: string, messageId: string) { | |
| 263 | + | queries.setQueryData<ChatSidebar>(chatKeys.sidebar(workspace), (data) => | |
| 264 | + | data ? { ...data, entries: data.entries.map((entry) => (entry.channel.id === channelId ? { ...entry, unread: 0, mentions: 0 } : entry)) } : data, | |
| 90 | 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 | + | }; | |
| 91 | 291 | } | |
| 92 | 292 | ||
| 293 | + | // ── Live ─────────────────────────────────────────────────────────────────── | |
| 294 | + | ||
| 93 | 295 | export type LiveStatus = "connecting" | "live" | "offline"; | |
| 94 | 296 | ||
| 297 | + | /** Who is typing, until when. */ | |
| 298 | + | type Typing = { member: MemberProfile; until: number }; | |
| 299 | + | ||
| 95 | 300 | /** | |
| 96 | − | * Keeps a channel's messages live while its screen is open: opens the | |
| 97 | − | * channel's socket with a ticket, applies what it says to the cache, and | |
| 98 | − | * opens it again (with a new ticket) when it drops. | |
| 301 | + | * Keeps a conversation live while its screen is open: applies what its | |
| 302 | + | * socket says to the cache, says who is typing, and lets the composer say | |
| 303 | + | * the viewer is (at most every 3 s, as the room takes it). | |
| 99 | 304 | */ | |
| 100 | − | export function useChannelLive(channelId: string): LiveStatus { | |
| 101 | − | const { client, workspace } = useSignedIn(); | |
| 305 | + | export function useChannelLive(channelId: string) { | |
| 306 | + | const { client, workspace, me } = useSignedIn(); | |
| 102 | 307 | const queries = useQueryClient(); | |
| 103 | 308 | const slug = workspace?.slug ?? ""; | |
| 104 | 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); | |
| 105 | 313 | ||
| 106 | 314 | useEffect(() => { | |
| 107 | 315 | if (!slug || !channelId) return; | |
| 108 | − | let socket: WebSocket | null = null; | |
| 109 | − | let closed = false; | |
| 110 | − | let retry: ReturnType<typeof setTimeout> | null = null; | |
| 111 | − | let attempts = 0; | |
| 112 | − | ||
| 113 | − | const open = async () => { | |
| 114 | − | setStatus("connecting"); | |
| 115 | − | const path = `/${encodeURIComponent(slug)}/-/chat/live`; | |
| 116 | − | try { | |
| 117 | − | const token = await client.token(); | |
| 118 | − | const response = await fetch(`${client.server.site}/-/live/ticket?path=${encodeURIComponent(path)}`, { | |
| 119 | − | headers: { authorization: `Bearer ${token}`, accept: "application/json" }, | |
| 120 | − | }); | |
| 121 | − | if (!response.ok) throw new Error(`ticket ${response.status}`); | |
| 122 | − | const { ticket } = (await response.json()) as { ticket: string }; | |
| 123 | − | if (closed) return; | |
| 124 | − | const query = new URLSearchParams({ channel: channelId, ticket }); | |
| 125 | − | socket = new WebSocket(`${socketUrl(client.server, path)}?${query}`); | |
| 126 | − | } catch { | |
| 127 | − | return again(); | |
| 128 | − | } | |
| 129 | − | socket.onopen = () => { | |
| 130 | − | attempts = 0; | |
| 131 | − | setStatus("live"); | |
| 132 | − | }; | |
| 133 | − | socket.onmessage = (event) => { | |
| 134 | − | let live: ChatLiveEvent; | |
| 135 | − | try { | |
| 136 | − | live = JSON.parse(String(event.data)) as ChatLiveEvent; | |
| 137 | − | } catch { | |
| 138 | − | return; | |
| 139 | − | } | |
| 140 | − | if (live.type === "message.created") addMessage(queries, slug, live.message); | |
| 141 | − | else if (live.type === "message.updated") replaceMessage(queries, slug, live.message); | |
| 142 | − | else if (live.type === "message.deleted" || live.type.startsWith("reaction.")) { | |
| 143 | − | void queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }); | |
| 316 | + | const socket = openSocket(client, `/${encodeURIComponent(slug)}/-/chat/live`, { channel: channelId }, { | |
| 317 | + | onStatus: setStatus, | |
| 318 | + | onReconnect: () => void queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }), | |
| 319 | + | onMessage: (data) => { | |
| 320 | + | const live = data as ChatLiveEvent; | |
| 321 | + | switch (live.type) { | |
| 322 | + | case "message.created": | |
| 323 | + | case "message.updated": | |
| 324 | + | upsertMessage(queries, slug, keepMine(queries, slug, live.message)); | |
| 325 | + | setTyping((now) => now.filter((one) => one.member.id !== live.message.author.id)); | |
| 326 | + | break; | |
| 327 | + | case "message.deleted": | |
| 328 | + | removeMessage(queries, slug, live.channel_id, live.id); | |
| 329 | + | break; | |
| 330 | + | case "reaction.added": | |
| 331 | + | case "reaction.removed": | |
| 332 | + | if (live.member.id !== me.id) applyReaction(queries, slug, live.channel_id, live.message_id, live.emoji, live.member, live.type === "reaction.added", false); | |
| 333 | + | break; | |
| 334 | + | case "typing": | |
| 335 | + | if (live.member.id === me.id) break; | |
| 336 | + | setTyping((now) => [...now.filter((one) => one.member.id !== live.member.id), { member: live.member, until: Date.parse(live.until) }]); | |
| 337 | + | break; | |
| 338 | + | case "channel.updated": | |
| 339 | + | void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }); | |
| 340 | + | break; | |
| 144 | 341 | } | |
| 145 | − | }; | |
| 146 | − | socket.onclose = () => { | |
| 147 | − | if (!closed) again(); | |
| 148 | − | }; | |
| 342 | + | }, | |
| 343 | + | }); | |
| 344 | + | send.current = socket.send; | |
| 345 | + | return () => { | |
| 346 | + | send.current = null; | |
| 347 | + | socket.close(); | |
| 149 | 348 | }; | |
| 349 | + | }, [client, queries, slug, channelId, me.id]); | |
| 150 | 350 | ||
| 151 | − | const again = () => { | |
| 152 | − | setStatus("offline"); | |
| 153 | − | if (closed) return; | |
| 154 | − | attempts += 1; | |
| 155 | − | retry = setTimeout(open, Math.min(30_000, 1_000 * 2 ** Math.min(attempts, 5))); | |
| 156 | − | }; | |
| 351 | + | // Typing lapses on its own. | |
| 352 | + | useEffect(() => { | |
| 353 | + | if (typing.length === 0) return; | |
| 354 | + | const next = Math.min(...typing.map((one) => one.until)) - Date.now(); | |
| 355 | + | const timer = setTimeout(() => setTyping((now) => now.filter((one) => one.until > Date.now())), Math.max(250, next)); | |
| 356 | + | return () => clearTimeout(timer); | |
| 357 | + | }, [typing]); | |
| 157 | 358 | ||
| 158 | − | void open(); | |
| 159 | − | return () => { | |
| 160 | − | closed = true; | |
| 161 | − | if (retry) clearTimeout(retry); | |
| 162 | − | socket?.close(); | |
| 163 | − | }; | |
| 164 | − | }, [client, queries, slug, channelId]); | |
| 359 | + | const sayTyping = useCallback(() => { | |
| 360 | + | const now = Date.now(); | |
| 361 | + | if (now - typedAt.current < 3000) return; | |
| 362 | + | typedAt.current = now; | |
| 363 | + | send.current?.({ type: "typing", channel_id: channelId }); | |
| 364 | + | }, [channelId]); | |
| 165 | 365 | ||
| 166 | − | return status; | |
| 366 | + | return { status, typing: typing.map((one) => one.member), sayTyping }; | |
| 167 | 367 | } | |
| 80 | 80 | return answer.value; | |
| 81 | 81 | } | |
| 82 | 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 | + | ||
| 83 | 88 | /** The current access token, refreshed first when it is about to expire. */ | |
| 84 | 89 | async token(): Promise<string> { | |
| 85 | 90 | const credentials = this.session.credentials; |
| 1 | + | import type { ChatSidebar, FeedEvent, FeedNotification, Presence } from "@g1t/contracts"; | |
| 2 | + | import { useQueryClient } from "@tanstack/react-query"; | |
| 3 | + | import * as Haptics from "expo-haptics"; | |
| 4 | + | import * as Notifications from "expo-notifications"; | |
| 5 | + | import { usePathname } from "expo-router"; | |
| 6 | + | import { type ReactNode, createContext, useContext, useEffect, useRef, useState } from "react"; | |
| 7 | + | import { AppState } from "react-native"; | |
| 8 | + | ||
| 9 | + | import { chatKeys } from "./chat"; | |
| 10 | + | import { openSocket } from "./live"; | |
| 11 | + | import { useSignedIn } from "./session"; | |
| 12 | + | import { viewing } from "./viewing"; | |
| 13 | + | ||
| 14 | + | /** | |
| 15 | + | * The person's feed, while the app is in front: the same socket every tab | |
| 16 | + | * of the site keeps open (`/-/live?workspace=<slug>`, services/notify). | |
| 17 | + | * It brings unread counts as they move, notifications as they happen (a | |
| 18 | + | * banner unless the conversation is open), and who is online. The app | |
| 19 | + | * says it is focused, so g1t pushes nothing to the phone while it is in | |
| 20 | + | * use; in the background the socket closes and pushes take over. | |
| 21 | + | */ | |
| 22 | + | ||
| 23 | + | type FeedState = { | |
| 24 | + | /** Unread in Chat, leaving out muted conversations; mentions apart. */ | |
| 25 | + | unread: number; | |
| 26 | + | mentions: number; | |
| 27 | + | /** A notification to show as a banner, until it is dismissed. */ | |
| 28 | + | banner: FeedNotification | null; | |
| 29 | + | dismiss: () => void; | |
| 30 | + | presence: Map<string, Presence>; | |
| 31 | + | }; | |
| 32 | + | ||
| 33 | + | const FeedContext = createContext<FeedState>({ unread: 0, mentions: 0, banner: null, dismiss: () => {}, presence: new Map() }); | |
| 34 | + | ||
| 35 | + | export function FeedProvider({ children }: { children: ReactNode }) { | |
| 36 | + | const { client, workspace } = useSignedIn(); | |
| 37 | + | const queries = useQueryClient(); | |
| 38 | + | const slug = workspace?.slug ?? ""; | |
| 39 | + | const path = usePathname(); | |
| 40 | + | const [active, setActive] = useState(AppState.currentState === "active"); | |
| 41 | + | const [counts, setCounts] = useState({ unread: 0, mentions: 0 }); | |
| 42 | + | const [banner, setBanner] = useState<FeedNotification | null>(null); | |
| 43 | + | const [presence, setPresence] = useState<Map<string, Presence>>(new Map()); | |
| 44 | + | const sendRef = useRef<((frame: unknown) => void) | null>(null); | |
| 45 | + | const pathRef = useRef(path); | |
| 46 | + | pathRef.current = path; | |
| 47 | + | ||
| 48 | + | useEffect(() => { | |
| 49 | + | const subscription = AppState.addEventListener("change", (state) => setActive(state === "active")); | |
| 50 | + | return () => subscription.remove(); | |
| 51 | + | }, []); | |
| 52 | + | ||
| 53 | + | useEffect(() => { | |
| 54 | + | if (!active || !slug) return; | |
| 55 | + | const socket = openSocket(client, "/-/live", { workspace: slug }, { | |
| 56 | + | onOpen: (send) => { | |
| 57 | + | sendRef.current = send; | |
| 58 | + | send({ type: "state", focused: true, path: pathRef.current }); | |
| 59 | + | }, | |
| 60 | + | onReconnect: () => void queries.invalidateQueries({ queryKey: chatKeys.all(slug) }), | |
| 61 | + | onMessage: (data) => { | |
| 62 | + | const event = data as FeedEvent; | |
| 63 | + | if (event.type === "counts" && event.workspace === slug) { | |
| 64 | + | setCounts({ unread: event.chat_unread, mentions: event.chat_mentions }); | |
| 65 | + | void Notifications.setBadgeCountAsync(event.chat_unread + event.chat_mentions).catch(() => undefined); | |
| 66 | + | const per = new Map(event.per_channel.map((row) => [row.channel_id, row])); | |
| 67 | + | queries.setQueryData<ChatSidebar>(chatKeys.sidebar(slug), (sidebar) => { | |
| 68 | + | if (!sidebar) return sidebar; | |
| 69 | + | const known = new Set(sidebar.entries.map((entry) => entry.channel.id)); | |
| 70 | + | // A conversation the app has not seen yet: read the sidebar again. | |
| 71 | + | if ([...per.keys()].some((id) => !known.has(id))) void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }); | |
| 72 | + | return { | |
| 73 | + | ...sidebar, | |
| 74 | + | entries: sidebar.entries.map((entry) => { | |
| 75 | + | const row = per.get(entry.channel.id); | |
| 76 | + | if (row) return { ...entry, unread: row.unread, mentions: row.mentions }; | |
| 77 | + | return event.complete ? { ...entry, unread: 0, mentions: 0 } : entry; | |
| 78 | + | }), | |
| 79 | + | }; | |
| 80 | + | }); | |
| 81 | + | } else if (event.type === "notification" && event.toast) { | |
| 82 | + | const note = event.notification; | |
| 83 | + | if (note.workspace !== slug || (note.channel_id && note.channel_id === viewing.channelId)) return; | |
| 84 | + | setBanner(note); | |
| 85 | + | void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => undefined); | |
| 86 | + | } else if (event.type === "presence" && event.workspace === slug) { | |
| 87 | + | setPresence((before) => { | |
| 88 | + | const next = event.full ? new Map<string, Presence>() : new Map(before); | |
| 89 | + | for (const person of event.people) next.set(person.user_id, person.presence); | |
| 90 | + | return next; | |
| 91 | + | }); | |
| 92 | + | } | |
| 93 | + | }, | |
| 94 | + | }); | |
| 95 | + | return () => { | |
| 96 | + | sendRef.current = null; | |
| 97 | + | socket.close(); | |
| 98 | + | }; | |
| 99 | + | }, [active, client, queries, slug]); | |
| 100 | + | ||
| 101 | + | // Where the app is, as a tab says where it is. | |
| 102 | + | useEffect(() => { | |
| 103 | + | sendRef.current?.({ type: "state", focused: true, path }); | |
| 104 | + | }, [path]); | |
| 105 | + | ||
| 106 | + | // A banner goes by itself. | |
| 107 | + | useEffect(() => { | |
| 108 | + | if (!banner) return; | |
| 109 | + | const timer = setTimeout(() => setBanner(null), 5000); | |
| 110 | + | return () => clearTimeout(timer); | |
| 111 | + | }, [banner]); | |
| 112 | + | ||
| 113 | + | return ( | |
| 114 | + | <FeedContext.Provider value={{ ...counts, banner, dismiss: () => setBanner(null), presence }}>{children}</FeedContext.Provider> | |
| 115 | + | ); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | export function useFeed(): FeedState { | |
| 119 | + | return useContext(FeedContext); | |
| 120 | + | } |
| 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 | + | } |
| 28 | 28 | ||
| 29 | 29 | /** | |
| 30 | 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 has no scope: | |
| 32 | − | * see the README's "Chat and OAuth tokens". | |
| 31 | + | * person can untick any of these on the approval page. `chat:*` opens | |
| 32 | + | * Chat's routes and live sockets on the site, and `notifications:*` the | |
| 33 | + | * phone's registration for pushes (`/-/notify`), and nothing else there. | |
| 33 | 34 | */ | |
| 34 | 35 | export const SCOPES = [ | |
| 36 | + | "chat:read", | |
| 37 | + | "chat:write", | |
| 35 | 38 | "account:read", | |
| 36 | 39 | "workspace:read", | |
| 37 | 40 | "repo:read", |
| 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 | + | } |
| 6 | 6 | import { ApiError, Client } from "./client"; | |
| 7 | 7 | import { type Credentials, clearCredentials, loadCredentials, loadServer, saveCredentials, saveServer } from "./credentials"; | |
| 8 | 8 | import { signInWithOAuth } from "./oauth"; | |
| 9 | + | import { unregisterPush } from "./push"; | |
| 9 | 10 | import type { Server } from "./server"; | |
| 10 | 11 | ||
| 11 | 12 | /** | |
| ⋯ | |||
| 126 | 127 | }, | |
| 127 | 128 | signOut: async () => { | |
| 128 | 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); | |
| 129 | 132 | if (server) await signedOut(server); | |
| 130 | 133 | }, | |
| 131 | 134 | changeServer: async (server) => { | |
| 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 }; |
| 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.
| 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": | |
| 190 | 190 | // Its own notification: a later message in the conversation does not replace it. | |
| 191 | 191 | assert.equal(payload.tag, "approval:approval:ses_1:2000000"); | |
| 192 | 192 | }); | |
| 193 | + | ||
| 194 | + | test("a person with only the phone app is pushed to: the feed counts browsers and phones together", () => { | |
| 195 | + | const dm = { kind: "dm" as const, workspace: "acme" }; | |
| 196 | + | const browsers = 0; | |
| 197 | + | const phones = 1; | |
| 198 | + | assert.deepEqual(decide({ prefs: dms, notification: dm, tabs: [], subscriptions: browsers + phones, now: NOW }), { toast: true, push: true }); | |
| 199 | + | // The same rules hold: not while a tab is in front of them, nor in Do Not Disturb. | |
| 200 | + | assert.equal(decide({ prefs: dms, notification: dm, tabs: [{ focused: true, seen_at: NOW }], subscriptions: browsers + phones, now: NOW }).push, false); | |
| 201 | + | assert.equal(decide({ prefs: dms, notification: dm, tabs: [], subscriptions: browsers + phones, now: NOW, dnd: true }).push, false); | |
| 202 | + | }); |
| 32 | 32 | // Web Push (RFC 8292): the public half of the VAPID key pair browsers | |
| 33 | 33 | // subscribe with, and who the push services may contact. The private | |
| 34 | 34 | // half is the secret VAPID_PRIVATE_KEY (scripts/ops/vapid-keys.mjs). | |
| 35 | − | // Empty: no pushes, everything else works. | |
| 35 | + | // Empty: no pushes, everything else works. Pushes to the phone app go | |
| 36 | + | // through Expo and need nothing here; the secret EXPO_ACCESS_TOKEN is | |
| 37 | + | // needed only once the Expo project turns on enhanced push security. | |
| 36 | 38 | "vars": { "VAPID_PUBLIC_KEY": "BBAhBFy-jgfEKm4IDEWKNN_5Nft9SjHqahXtpuZf1Aohop03XduSZgyat-hhDKXAP2hdtMv8yPX7TfqWyR6vJTo", "VAPID_SUBJECT": "https://g1t.sh" }, | |
| 37 | 39 | "observability": { "enabled": true, "head_sampling_rate": 0.1 } | |
| 38 | 40 | } |