The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server.
Chat reads the sidebar and messages, posts and marks read through the site's own chat routes, live over the channel's socket with a ticket. The site takes those only from a token with "Use the website as you", which OAuth tokens are not, so with the app's sign-in Chat says it needs another one; the README sets out the two ways the server can close that before a release. Code lists a workspace's repositories, their pull requests and issues, a pull request's checks, conversation and diff, and approves or asks for changes; an issue takes comments. g1t:// links open the screens the app has, as on the desktop, and the rest open on the site. Customize replaces the Marketplace on the phone: Skills, Connectors and Plugins, from the catalogs g1t ships with. Its three parts are defined in @g1t/contracts (customize.ts) so the site can move onto the same model, and contracts exports oauth, customize and skills as subpaths so the app bundles only what it uses. eas.json has development, preview and production profiles; APP_VARIANT gives each its own package (sh.g1t.app, .dev, .preview), so they install side by side. Metro pins React to the app's 19.2.3, since the site's newer React is hoisted to the root and React Native needs its own. The icons are drawn from the mark (npm run icons). The phone app guide is new, and the desktop guide and README point to it.
- Claude-Session:
- https://claude.ai/code/session_01MQE64a4WNN14F8GELCiuc9
| 42 | 42 | | Slides, designs and dashboards as artifacts | Coming | | |
| 43 | 43 | | Scratchpads: tools, reports and agents anyone can build on a copy of the data, promoted after review | Coming | | |
| 44 | 44 | | Desktop app for Windows, macOS and Linux (Tauri): the site in a window of its own, notifications from the system, a badge, g1t:// links, updates that install themselves | Preview | | |
| 45 | − | | A runner inside the desktop app, and a phone app (Expo) | Coming | | |
| 45 | + | | A runner inside the desktop app | Coming | | |
| 46 | + | | A phone app for Android and iOS (Expo, in `apps/mobile`): Chat, Code and Customize, signed in with OAuth; Android first | Coming | | |
| 46 | 47 | ||
| 47 | 48 | ### Agents | |
| 48 | 49 |
| 82 | 82 | { label: 'Home', slug: 'guides/home' }, | |
| 83 | 83 | { label: 'Notifications', slug: 'guides/notifications' }, | |
| 84 | 84 | { label: 'The desktop app', slug: 'guides/desktop' }, | |
| 85 | + | { label: 'The phone app', slug: 'guides/mobile' }, | |
| 85 | 86 | { label: 'Search and Explore', slug: 'guides/search' }, | |
| 86 | 87 | { label: 'Status and incidents', slug: 'guides/status' }, | |
| 87 | 88 | ], |
| 113 | 113 | page is open, the window shows the web view's own error until it is | |
| 114 | 114 | back; starting without a network shows the app's own page, with a retry. | |
| 115 | 115 | - **Notarization on macOS**, so the first open needs no right-click. | |
| 116 | − | - **A phone app.** | |
| 116 | + | - **A phone app.** It is being built: see [the phone app](/guides/mobile/). | |
| 117 | 117 | ||
| 118 | 118 | ## For operators: releasing the app | |
| 119 | 119 |
| 1 | + | --- | |
| 2 | + | title: The phone app | |
| 3 | + | description: g1t on Android and iOS, built with Expo. Chat, 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. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | The phone app is g1t on Android and iOS, with its own screens for what | |
| 7 | + | you do away from a desk: 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. | |
| 11 | + | ||
| 12 | + | It is not in Google Play or the App Store yet. Android comes first. Until | |
| 13 | + | then, it is built from `apps/mobile` in | |
| 14 | + | [g1t's repository](https://g1t.sh/flagon-io/g1t), as | |
| 15 | + | [For operators](#for-operators-building-and-releasing) says. | |
| 16 | + | ||
| 17 | + | | Tab | What is on it | | |
| 18 | + | | --- | --- | | |
| 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**. | | |
| 23 | + | ||
| 24 | + | ## Signing in | |
| 25 | + | ||
| 26 | + | **Sign in with g1t** opens g1t.sh in your phone's browser, where you sign | |
| 27 | + | in as usual (with your password, GitHub, two-factor) and approve the app. | |
| 28 | + | The browser sends you back to the app, signed in. The app never sees your | |
| 29 | + | password. This is [OAuth](/guides/authentication/#signing-in-with-oauth): | |
| 30 | + | the app is listed as **g1t for Android** or **g1t for iOS** in | |
| 31 | + | [Settings → Connected applications](https://g1t.sh/settings/applications), | |
| 32 | + | where **Change access** narrows what it can do and **Sign out** ends it at | |
| 33 | + | once. | |
| 34 | + | ||
| 35 | + | The approval page lists what the app asks for: reading your account, | |
| 36 | + | workspaces, repositories, checks and workflows, reading and writing | |
| 37 | + | issues, pull requests and notifications, recalling memory, reading | |
| 38 | + | artifacts, and putting agents to work. Untick what you would rather it | |
| 39 | + | could not do. | |
| 40 | + | ||
| 41 | + | **Use an access token** signs in with a | |
| 42 | + | [personal access token](/guides/authentication/#access-tokens) you paste | |
| 43 | + | in instead. The token stays on your phone, in its keystore. | |
| 44 | + | ||
| 45 | + | ### Chat and the app's sign-in | |
| 46 | + | ||
| 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. | |
| 55 | + | ||
| 56 | + | ## Links | |
| 57 | + | ||
| 58 | + | A `g1t://` link opens the app, as it opens [the desktop app](/guides/desktop/#links): | |
| 59 | + | `g1t://acme/-/chat/dm/chn_1` is that conversation. The app shows the | |
| 60 | + | pages it has screens for: | |
| 61 | + | ||
| 62 | + | | Link | Opens | | |
| 63 | + | | --- | --- | | |
| 64 | + | | `g1t://<workspace>/-/chat`, `…/-/chat/<channel>`, `…/-/chat/dm/<id>` | Chat, or the conversation, in that workspace | | |
| 65 | + | | `g1t://<owner>/<repo>` | The repository | | |
| 66 | + | | `g1t://<owner>/<repo>/pull/<number>`, `…/issues/<number>` | The pull request or issue | | |
| 67 | + | | `g1t://<workspace>/-/marketplace…`, `…/-/agents/skills` | Customize | | |
| 68 | + | ||
| 69 | + | Any other page opens on the site, in the browser. | |
| 70 | + | ||
| 71 | + | ## Your own g1t | |
| 72 | + | ||
| 73 | + | If you [run g1t yourself](/guides/self-hosting/), choose **Server** on the | |
| 74 | + | sign-in screen and enter your site's address, such as | |
| 75 | + | `https://g1t.example.com`. The app takes the API to be `api.` in front of | |
| 76 | + | it (`https://api.g1t.example.com`); if yours is elsewhere, enter it under | |
| 77 | + | **API**. **Use g1t.sh** goes back. | |
| 78 | + | ||
| 79 | + | ## What's not there yet | |
| 80 | + | ||
| 81 | + | - **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 | + | - **Home, Notifications, Agents and Artifacts** as tabs of their own. | |
| 87 | + | 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. | |
| 91 | + | - **https links.** `g1t.sh` addresses open the site in the browser, not | |
| 92 | + | the app, until g1t.sh publishes the files Android and iOS check first. | |
| 93 | + | ||
| 94 | + | ## For operators: building and releasing | |
| 95 | + | ||
| 96 | + | The app lives in `apps/mobile`: [Expo](https://expo.dev) with Expo | |
| 97 | + | Router, in TypeScript, sharing types with the site through | |
| 98 | + | `@g1t/contracts`. It builds with EAS Build. `apps/mobile/README.md` has | |
| 99 | + | the commands for running it on a phone and the first-time setup. | |
| 100 | + | ||
| 101 | + | | Build | Command | What it makes | | |
| 102 | + | | --- | --- | --- | | |
| 103 | + | | Development | `eas build --profile development --platform android` | An APK of `g1t (dev)` (`sh.g1t.app.dev`) that loads its code from your computer. | | |
| 104 | + | | Preview | `npm run build:android:apk -w @g1t/mobile` | An APK of `g1t (beta)` (`sh.g1t.app.preview`) to install on testers' phones. | | |
| 105 | + | | Production | `npm run build:android -w @g1t/mobile` | An Android App Bundle of `g1t` (`sh.g1t.app`) for Google Play. | | |
| 106 | + | ||
| 107 | + | The three install side by side. `eas submit --platform android` sends | |
| 108 | + | the latest production build to Google Play's internal testing track as a | |
| 109 | + | draft. |
| 1 | + | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files | |
| 2 | + | ||
| 3 | + | # dependencies | |
| 4 | + | node_modules/ | |
| 5 | + | ||
| 6 | + | # Expo | |
| 7 | + | .expo/ | |
| 8 | + | dist/ | |
| 9 | + | web-build/ | |
| 10 | + | expo-env.d.ts | |
| 11 | + | ||
| 12 | + | # Native | |
| 13 | + | .kotlin/ | |
| 14 | + | *.orig.* | |
| 15 | + | *.jks | |
| 16 | + | *.p8 | |
| 17 | + | *.p12 | |
| 18 | + | *.key | |
| 19 | + | *.mobileprovision | |
| 20 | + | ||
| 21 | + | # Metro | |
| 22 | + | .metro-health-check* | |
| 23 | + | ||
| 24 | + | # debug | |
| 25 | + | npm-debug.* | |
| 26 | + | yarn-debug.* | |
| 27 | + | yarn-error.* | |
| 28 | + | ||
| 29 | + | # macOS | |
| 30 | + | .DS_Store | |
| 31 | + | *.pem | |
| 32 | + | ||
| 33 | + | # local env files | |
| 34 | + | .env*.local | |
| 35 | + | ||
| 36 | + | # typescript | |
| 37 | + | *.tsbuildinfo | |
| 38 | + | ||
| 39 | + | ||
| 40 | + | # generated native folders | |
| 41 | + | /ios | |
| 42 | + | /android |
| 1 | + | # g1t for Android and iOS | |
| 2 | + | ||
| 3 | + | The phone app: [Expo](https://expo.dev) (SDK 57, React Native 0.86) with | |
| 4 | + | Expo Router, in TypeScript. Chat, Code and Customize, signed in with g1t's | |
| 5 | + | own OAuth. Android is the first target. What it does for people is in the | |
| 6 | + | guide, [The phone app](../docs/src/content/docs/guides/mobile.md). | |
| 7 | + | ||
| 8 | + | ## Layout | |
| 9 | + | ||
| 10 | + | ```text | |
| 11 | + | app.config.ts name, ids and icons per build variant (APP_VARIANT) | |
| 12 | + | eas.json EAS Build profiles: development, preview, production | |
| 13 | + | metro.config.js one React for the app in the monorepo | |
| 14 | + | scripts/icons.mjs draws the icons from packages/theme/mark.svg | |
| 15 | + | src/ | |
| 16 | + | app/ the screens (Expo Router: one file, one route) | |
| 17 | + | sign-in.tsx Sign in with g1t, an access token, or another server | |
| 18 | + | oauth/callback.tsx where the site sends sign-in back (g1t://oauth/callback) | |
| 19 | + | (app)/ signed in: the tabs | |
| 20 | + | chat/ conversations, and one conversation, live | |
| 21 | + | code/ repositories, pull requests, issues, diffs | |
| 22 | + | customize/ Skills, Connectors, Plugins | |
| 23 | + | you/ workspaces, how you signed in, sign out | |
| 24 | + | +native-intent.tsx g1t:// links to screens (lib/links.ts) | |
| 25 | + | components/ the building blocks, themed (ui.tsx), and Code's | |
| 26 | + | lib/ | |
| 27 | + | server.ts which g1t: site and API addresses | |
| 28 | + | oauth.ts OAuth 2.1 with PKCE against the server's metadata | |
| 29 | + | credentials.ts the server and credentials, in the phone's keystore | |
| 30 | + | client.ts every request: refresh, errors, the site or the API | |
| 31 | + | session.tsx who is signed in, to which workspace | |
| 32 | + | chat.ts, code.ts the data for Chat and Code (TanStack Query) | |
| 33 | + | customize.ts Customize's listings, from @g1t/contracts' catalogs | |
| 34 | + | theme/ packages/theme/tokens.css's colours, for React Native | |
| 35 | + | ``` | |
| 36 | + | ||
| 37 | + | Shared types and catalogs come from `@g1t/contracts` (by subpath, such as | |
| 38 | + | `@g1t/contracts/oauth`, so the app does not bundle every module). | |
| 39 | + | Customize's three parts are defined there too (`customize.ts`), so the | |
| 40 | + | site can move the Marketplace onto the same model. | |
| 41 | + | ||
| 42 | + | ## How it talks to g1t | |
| 43 | + | ||
| 44 | + | | What | Where | How it is signed in | | |
| 45 | + | | --- | --- | --- | | |
| 46 | + | | Who you are, your workspaces | `GET {api}/user` | `Authorization: Bearer g1t_…` | | |
| 47 | + | | 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 | | |
| 49 | + | | Sign-in | `{api}/.well-known/oauth-authorization-server`, `{site}/oauth/authorize`, `{api}/oauth/token` | OAuth 2.1, PKCE (S256), public client | | |
| 50 | + | ||
| 51 | + | The OAuth client is not registered anywhere: its id encodes its name and | |
| 52 | + | redirect (`encodeOAuthClient`), so `g1t for Android` with | |
| 53 | + | `g1t://oauth/callback` is the same client on every phone. Access tokens | |
| 54 | + | last 30 days and are refreshed a minute before they expire; a refresh | |
| 55 | + | token works once, so `lib/client.ts` shares one refresh between | |
| 56 | + | requests. | |
| 57 | + | ||
| 58 | + | ### Open: Chat with the app's OAuth sign-in | |
| 59 | + | ||
| 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: | |
| 66 | + | ||
| 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. | |
| 76 | + | ||
| 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. | |
| 79 | + | ||
| 80 | + | ## Run it | |
| 81 | + | ||
| 82 | + | You need Node 22 (the repository's), and for Android, Android Studio with | |
| 83 | + | an emulator or a phone with USB debugging. | |
| 84 | + | ||
| 85 | + | ```sh | |
| 86 | + | npm install # at the repository's root: one workspace | |
| 87 | + | cd apps/mobile | |
| 88 | + | npx expo run:android # builds a development app and starts Metro | |
| 89 | + | ``` | |
| 90 | + | ||
| 91 | + | `expo run:android` makes the native project (`android/`, ignored by git) | |
| 92 | + | and installs `g1t (dev)`. After the first build, `npx expo start` and | |
| 93 | + | the installed app are enough until a native module changes. | |
| 94 | + | ||
| 95 | + | Expo Go works for screens that need nothing native beyond what Go has, | |
| 96 | + | and sign-in comes back to Go's own `exp://` address (allowed as a | |
| 97 | + | redirect). `g1t://` links need a development build. | |
| 98 | + | ||
| 99 | + | Checks, which CI runs from the root (`npm run typecheck`, `npm test`): | |
| 100 | + | ||
| 101 | + | ```sh | |
| 102 | + | npm run typecheck -w @g1t/mobile | |
| 103 | + | npm test -w @g1t/mobile # lib/*.test.ts under Node | |
| 104 | + | ``` | |
| 105 | + | ||
| 106 | + | ## Set up EAS and ship Android | |
| 107 | + | ||
| 108 | + | Once, by someone with access to the Expo account and Google Play: | |
| 109 | + | ||
| 110 | + | 1. `npm install -g eas-cli`, then `eas login`. | |
| 111 | + | 2. In `apps/mobile`, `eas init`. It makes the EAS project and prints its | |
| 112 | + | id: put it in `EAS_PROJECT` in `app.config.ts` (the config is code, so | |
| 113 | + | `eas init` cannot write it there). Set `EAS_OWNER` in your shell if the | |
| 114 | + | project belongs to an organization. | |
| 115 | + | 3. `eas build --profile preview --platform android`. EAS makes and keeps | |
| 116 | + | the upload keystore the first time; say yes. The build ends with a link | |
| 117 | + | and a QR code to install the APK. | |
| 118 | + | 4. In the [Google Play Console](https://play.google.com/console), create | |
| 119 | + | the app with the package `sh.g1t.app`, and turn on Play App Signing. | |
| 120 | + | 5. `npm run build:android` makes the production App Bundle. Upload the | |
| 121 | + | first one by hand on Play's **Internal testing** track: Google requires | |
| 122 | + | that before its API accepts uploads. | |
| 123 | + | 6. For `eas submit`, make a Google Cloud service account with access to | |
| 124 | + | the app in the Play Console, download its JSON key, and add it to the | |
| 125 | + | EAS project's credentials (`eas credentials`). From then on, | |
| 126 | + | `eas submit --platform android --latest` sends a build to internal | |
| 127 | + | testing as a draft (`eas.json`'s `submit.production`). | |
| 128 | + | ||
| 129 | + | iOS is the same with `--platform ios`, an Apple Developer account and the | |
| 130 | + | bundle id `sh.g1t.app`; EAS makes the certificates. | |
| 131 | + | ||
| 132 | + | ### Before the first store release | |
| 133 | + | ||
| 134 | + | - **Chat with OAuth**, above. | |
| 135 | + | - **Push notifications**: `expo-notifications`, and in `services/notify` | |
| 136 | + | a subscription kind for Expo push tokens (or FCM and APNs) beside Web | |
| 137 | + | Push, registered from the app after sign-in. | |
| 138 | + | - **App Links**: `https://g1t.sh/.well-known/assetlinks.json` naming | |
| 139 | + | `sh.g1t.app` and Play's signing certificate's SHA-256, then an | |
| 140 | + | `intentFilters` entry with `autoVerify` in `app.config.ts`, so g1t.sh | |
| 141 | + | links open the app. iOS needs `apple-app-site-association` the same way. | |
| 142 | + | - **The brand's faces**: TTF copies of Hanken Grotesk, Bricolage | |
| 143 | + | Grotesque and IBM Plex Mono (packages/theme ships woff2), loaded with | |
| 144 | + | `expo-font`. | |
| 145 | + | - **Agents' faces**: `components/ui.tsx`'s `Avatar` draws a letter for an | |
| 146 | + | agent; the site's `AgentFace` should be shared and drawn here. | |
| 147 | + | - **The store listing**: screenshots, a privacy policy URL, and Play's | |
| 148 | + | data safety form (the app keeps the server and credentials in the | |
| 149 | + | keystore, and sends nothing anywhere but the g1t it is pointed at). | |
| 150 | + | ||
| 151 | + | ## Notes | |
| 152 | + | ||
| 153 | + | - **React is pinned.** The site uses a newer React than React Native 0.86 | |
| 154 | + | allows, so the app has its own `react` (19.2.3) and `metro.config.js` | |
| 155 | + | resolves every `react` import to it. `expo-doctor` reports the root's | |
| 156 | + | copy as a duplicate; that is expected here, and the bundle has one. | |
| 157 | + | - **Icons** are drawn from the mark: `npm run icons -w @g1t/mobile`. | |
| 158 | + | - **Versions**: `version` in `app.config.ts` is what people see; | |
| 159 | + | EAS counts build numbers itself (`appVersionSource: remote`). Over-the-air | |
| 160 | + | updates go to builds of the same `version` (`runtimeVersion`). |
| 1 | + | import type { ConfigContext, ExpoConfig } from "expo/config"; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * The phone app's configuration. `APP_VARIANT` (set by eas.json's | |
| 5 | + | * profiles) picks an identity, so a development build, a preview build | |
| 6 | + | * and the store build install side by side on one phone: | |
| 7 | + | * | |
| 8 | + | * | Variant | Android package / iOS bundle | Name | | |
| 9 | + | * | ------------- | ---------------------------- | ----------- | | |
| 10 | + | * | `development` | `sh.g1t.app.dev` | g1t (dev) | | |
| 11 | + | * | `preview` | `sh.g1t.app.preview` | g1t (beta) | | |
| 12 | + | * | production | `sh.g1t.app` | g1t | | |
| 13 | + | * | |
| 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. | |
| 16 | + | */ | |
| 17 | + | ||
| 18 | + | /** The EAS project's id, from `eas init`. `EAS_PROJECT_ID` overrides it. */ | |
| 19 | + | const EAS_PROJECT: string | undefined = undefined; | |
| 20 | + | ||
| 21 | + | const variant = process.env.APP_VARIANT; | |
| 22 | + | const suffix = variant === "development" ? ".dev" : variant === "preview" ? ".preview" : ""; | |
| 23 | + | const name = variant === "development" ? "g1t (dev)" : variant === "preview" ? "g1t (beta)" : "g1t"; | |
| 24 | + | const id = `sh.g1t.app${suffix}`; | |
| 25 | + | const projectId = process.env.EAS_PROJECT_ID ?? EAS_PROJECT; | |
| 26 | + | ||
| 27 | + | /** g1t's background, dark as the site is by default (packages/theme/tokens.css `--g1t-bg`). */ | |
| 28 | + | const BACKGROUND = "#0f0f11"; | |
| 29 | + | ||
| 30 | + | export default ({ config }: ConfigContext): ExpoConfig => ({ | |
| 31 | + | ...config, | |
| 32 | + | name, | |
| 33 | + | slug: "g1t", | |
| 34 | + | owner: process.env.EAS_OWNER, | |
| 35 | + | version: "0.1.0", | |
| 36 | + | orientation: "portrait", | |
| 37 | + | icon: "./assets/images/icon.png", | |
| 38 | + | // g1t:// links, as on the desktop, and where sign-in comes back to. | |
| 39 | + | scheme: "g1t", | |
| 40 | + | userInterfaceStyle: "automatic", | |
| 41 | + | backgroundColor: BACKGROUND, | |
| 42 | + | ios: { | |
| 43 | + | bundleIdentifier: id, | |
| 44 | + | supportsTablet: true, | |
| 45 | + | config: { usesNonExemptEncryption: false }, | |
| 46 | + | }, | |
| 47 | + | android: { | |
| 48 | + | package: id, | |
| 49 | + | adaptiveIcon: { | |
| 50 | + | backgroundColor: BACKGROUND, | |
| 51 | + | foregroundImage: "./assets/images/android-icon-foreground.png", | |
| 52 | + | monochromeImage: "./assets/images/android-icon-monochrome.png", | |
| 53 | + | }, | |
| 54 | + | predictiveBackGestureEnabled: true, | |
| 55 | + | softwareKeyboardLayoutMode: "resize", | |
| 56 | + | }, | |
| 57 | + | plugins: [ | |
| 58 | + | "expo-router", | |
| 59 | + | "expo-secure-store", | |
| 60 | + | "expo-web-browser", | |
| 61 | + | [ | |
| 62 | + | "expo-splash-screen", | |
| 63 | + | { | |
| 64 | + | backgroundColor: BACKGROUND, | |
| 65 | + | image: "./assets/images/splash-icon.png", | |
| 66 | + | imageWidth: 96, | |
| 67 | + | }, | |
| 68 | + | ], | |
| 69 | + | ], | |
| 70 | + | experiments: { | |
| 71 | + | reactCompiler: true, | |
| 72 | + | }, | |
| 73 | + | runtimeVersion: { policy: "appVersion" }, | |
| 74 | + | ...(projectId ? { updates: { url: `https://u.expo.dev/${projectId}` } } : {}), | |
| 75 | + | extra: { | |
| 76 | + | ...config.extra, | |
| 77 | + | ...(projectId ? { eas: { projectId } } : {}), | |
| 78 | + | }, | |
| 79 | + | }); |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
| 1 | + | { | |
| 2 | + | "cli": { | |
| 3 | + | "version": ">= 16.0.0", | |
| 4 | + | "appVersionSource": "remote" | |
| 5 | + | }, | |
| 6 | + | "build": { | |
| 7 | + | "base": { | |
| 8 | + | "node": "22.22.0" | |
| 9 | + | }, | |
| 10 | + | "development": { | |
| 11 | + | "extends": "base", | |
| 12 | + | "developmentClient": true, | |
| 13 | + | "distribution": "internal", | |
| 14 | + | "env": { "APP_VARIANT": "development" }, | |
| 15 | + | "android": { "buildType": "apk" } | |
| 16 | + | }, | |
| 17 | + | "preview": { | |
| 18 | + | "extends": "base", | |
| 19 | + | "distribution": "internal", | |
| 20 | + | "channel": "preview", | |
| 21 | + | "env": { "APP_VARIANT": "preview" }, | |
| 22 | + | "android": { "buildType": "apk" } | |
| 23 | + | }, | |
| 24 | + | "production": { | |
| 25 | + | "extends": "base", | |
| 26 | + | "channel": "production", | |
| 27 | + | "autoIncrement": true, | |
| 28 | + | "android": { "buildType": "app-bundle" } | |
| 29 | + | } | |
| 30 | + | }, | |
| 31 | + | "submit": { | |
| 32 | + | "production": { | |
| 33 | + | "android": { "track": "internal", "releaseStatus": "draft" } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } |
| 1 | + | // Metro in the g1t monorepo. Expo finds the workspace root and | |
| 2 | + | // @g1t/contracts by itself; what it needs told is that React comes from | |
| 3 | + | // this app alone. The site pins a newer React at the root, and React | |
| 4 | + | // Native must run with the exact React it was built for (19.2.3 with | |
| 5 | + | // Expo SDK 57), so `react` and its subpaths resolve from here wherever | |
| 6 | + | // they are imported, including from packages hoisted to the root. | |
| 7 | + | const path = require("node:path"); | |
| 8 | + | const { getDefaultConfig } = require("expo/metro-config"); | |
| 9 | + | ||
| 10 | + | const config = getDefaultConfig(__dirname); | |
| 11 | + | const here = path.join(__dirname, "package.json"); | |
| 12 | + | ||
| 13 | + | const resolveRequest = config.resolver.resolveRequest; | |
| 14 | + | config.resolver.resolveRequest = (context, moduleName, platform) => { | |
| 15 | + | const pinned = moduleName === "react" || moduleName.startsWith("react/"); | |
| 16 | + | const from = pinned ? { ...context, originModulePath: here } : context; | |
| 17 | + | return resolveRequest ? resolveRequest(from, moduleName, platform) : context.resolveRequest(from, moduleName, platform); | |
| 18 | + | }; | |
| 19 | + | ||
| 20 | + | module.exports = config; |
| 1 | + | { | |
| 2 | + | "name": "@g1t/mobile", | |
| 3 | + | "private": true, | |
| 4 | + | "version": "0.1.0", | |
| 5 | + | "description": "The g1t phone app (Expo): Chat and Code first, signed in with OAuth, for Android and iOS.", | |
| 6 | + | "main": "expo-router/entry", | |
| 7 | + | "scripts": { | |
| 8 | + | "start": "expo start", | |
| 9 | + | "android": "expo run:android", | |
| 10 | + | "ios": "expo run:ios", | |
| 11 | + | "prebuild": "expo prebuild --clean", | |
| 12 | + | "typecheck": "tsc --noEmit", | |
| 13 | + | "test": "node --test \"src/lib/*.test.ts\"", | |
| 14 | + | "doctor": "expo-doctor", | |
| 15 | + | "icons": "node scripts/icons.mjs", | |
| 16 | + | "build:android": "eas build --platform android --profile production", | |
| 17 | + | "build:android:apk": "eas build --platform android --profile preview", | |
| 18 | + | "build:ios": "eas build --platform ios --profile production" | |
| 19 | + | }, | |
| 20 | + | "dependencies": { | |
| 21 | + | "@g1t/contracts": "*", | |
| 22 | + | "@tanstack/react-query": "^5.104.1", | |
| 23 | + | "expo": "~57.0.27", | |
| 24 | + | "expo-auth-session": "~57.0.14", | |
| 25 | + | "expo-clipboard": "~57.0.2", | |
| 26 | + | "expo-constants": "~57.0.21", | |
| 27 | + | "expo-crypto": "~57.0.3", | |
| 28 | + | "expo-font": "~57.0.4", | |
| 29 | + | "expo-image": "~57.0.5", | |
| 30 | + | "expo-linking": "~57.0.12", | |
| 31 | + | "expo-router": "~57.0.25", | |
| 32 | + | "expo-secure-store": "~57.0.4", | |
| 33 | + | "expo-splash-screen": "~57.0.9", | |
| 34 | + | "expo-status-bar": "~57.0.1", | |
| 35 | + | "expo-system-ui": "~57.0.4", | |
| 36 | + | "expo-web-browser": "~57.0.3", | |
| 37 | + | "lucide-react-native": "^1.55.0", | |
| 38 | + | "react": "19.2.3", | |
| 39 | + | "react-native": "0.86.3", | |
| 40 | + | "react-native-gesture-handler": "~2.32.0", | |
| 41 | + | "react-native-reanimated": "4.5.1", | |
| 42 | + | "react-native-safe-area-context": "~5.7.0", | |
| 43 | + | "react-native-screens": "~4.26.0", | |
| 44 | + | "react-native-svg": "15.15.4", | |
| 45 | + | "react-native-worklets": "0.10.1" | |
| 46 | + | }, | |
| 47 | + | "devDependencies": { | |
| 48 | + | "@types/react": "~19.2.2", | |
| 49 | + | "sharp": "^0.35.5", | |
| 50 | + | "typescript": "~6.0.3" | |
| 51 | + | } | |
| 52 | + | } |
| 1 | + | // Draws the app's icons from the g1t mark (packages/theme/mark.svg): | |
| 2 | + | // `npm run icons -w @g1t/mobile` after the mark or the background changes. | |
| 3 | + | // | |
| 4 | + | // - icon.png: the store and iOS icon, the mark on g1t's background. | |
| 5 | + | // - android-icon-foreground.png: the adaptive icon's mark, inside the | |
| 6 | + | // middle 66% Android keeps whatever the launcher's mask. | |
| 7 | + | // - android-icon-monochrome.png: the same in white, for themed icons. | |
| 8 | + | // - splash-icon.png: the mark alone, on the splash screen's background. | |
| 9 | + | import { readFile } from "node:fs/promises"; | |
| 10 | + | import { fileURLToPath } from "node:url"; | |
| 11 | + | import sharp from "sharp"; | |
| 12 | + | ||
| 13 | + | const here = (path) => fileURLToPath(new URL(path, import.meta.url)); | |
| 14 | + | const mark = await readFile(here("../../../packages/theme/mark.svg"), "utf8"); | |
| 15 | + | const BACKGROUND = "#0f0f11"; | |
| 16 | + | ||
| 17 | + | /** The mark at `scale` of a `size` square, recoloured, on `background` or nothing. */ | |
| 18 | + | async function draw(file, { size, scale, color, background }) { | |
| 19 | + | const inner = Math.round(size * scale); | |
| 20 | + | const svg = Buffer.from(mark.replace(/#b6a8ff/g, color ?? "#b6a8ff").replace("<svg ", `<svg width="${inner}" height="${inner}" `)); | |
| 21 | + | const glyph = await sharp(svg, { density: 72 * (inner / 80) }).resize(inner, inner).png().toBuffer(); | |
| 22 | + | await sharp({ create: { width: size, height: size, channels: 4, background: background ?? { r: 0, g: 0, b: 0, alpha: 0 } } }) | |
| 23 | + | .composite([{ input: glyph, gravity: "centre" }]) | |
| 24 | + | .png() | |
| 25 | + | .toFile(here(`../assets/images/${file}`)); | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | await draw("icon.png", { size: 1024, scale: 0.62, background: BACKGROUND }); | |
| 29 | + | await draw("android-icon-foreground.png", { size: 1024, scale: 0.46 }); | |
| 30 | + | await draw("android-icon-monochrome.png", { size: 1024, scale: 0.46, color: "#ffffff" }); | |
| 31 | + | 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."); |
| 1 | + | import { Tabs, useGlobalSearchParams } from "expo-router"; | |
| 2 | + | import { Blocks, CircleUser, Code2, MessagesSquare } from "lucide-react-native"; | |
| 3 | + | import { useEffect } from "react"; | |
| 4 | + | ||
| 5 | + | import { useSignedIn } from "@/lib/session"; | |
| 6 | + | import { useColors } from "@/theme"; | |
| 7 | + | ||
| 8 | + | /** | |
| 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. | |
| 12 | + | */ | |
| 13 | + | export default function AppLayout() { | |
| 14 | + | const colors = useColors(); | |
| 15 | + | const { workspace, selectWorkspace } = useSignedIn(); | |
| 16 | + | // A link names its workspace (`/chat?workspace=acme`, lib/links.ts): switch to it. | |
| 17 | + | const { workspace: linked } = useGlobalSearchParams<{ workspace?: string }>(); | |
| 18 | + | useEffect(() => { | |
| 19 | + | if (linked && linked.toLowerCase() !== workspace?.slug.toLowerCase()) void selectWorkspace(linked); | |
| 20 | + | }, [linked, workspace?.slug, selectWorkspace]); | |
| 21 | + | ||
| 22 | + | return ( | |
| 23 | + | <Tabs | |
| 24 | + | screenOptions={{ | |
| 25 | + | headerShown: false, | |
| 26 | + | tabBarActiveTintColor: colors.accent, | |
| 27 | + | tabBarInactiveTintColor: colors.muted, | |
| 28 | + | tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.line }, | |
| 29 | + | }} | |
| 30 | + | > | |
| 31 | + | <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} /> }} /> | |
| 36 | + | </Tabs> | |
| 37 | + | ); | |
| 38 | + | } |
| 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"; | |
| 8 | + | ||
| 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"; | |
| 15 | + | ||
| 16 | + | /** | |
| 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`). | |
| 20 | + | */ | |
| 21 | + | export default function Conversation() { | |
| 22 | + | const params = useLocalSearchParams<{ channel: string; title?: string }>(); | |
| 23 | + | const { client, workspace, server } = useSignedIn(); | |
| 24 | + | const sidebar = useChatSidebar(); | |
| 25 | + | const entry = sidebar.data?.entries.find((one) => one.channel.id === params.channel || one.channel.name === params.channel); | |
| 26 | + | // 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]); | |
| 46 | + | ||
| 47 | + | const send = () => { | |
| 48 | + | const body = draft.trim(); | |
| 49 | + | if (!body || post.isPending) return; | |
| 50 | + | post.mutate(body, { onSuccess: () => setDraft("") }); | |
| 51 | + | }; | |
| 52 | + | ||
| 53 | + | return ( | |
| 54 | + | <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 }]} | |
| 83 | + | /> | |
| 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 | + | ) : null} | |
| 93 | + | </KeyboardAvoidingView> | |
| 94 | + | </Screen> | |
| 95 | + | ); | |
| 96 | + | } | |
| 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 { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 1 | + | import type { ChatSidebarEntry } from "@g1t/contracts"; | |
| 2 | + | import { Stack, router } from "expo-router"; | |
| 3 | + | import { Hash, Lock, MessagesSquare } from "lucide-react-native"; | |
| 4 | + | import { RefreshControl, SectionList, View } from "react-native"; | |
| 5 | + | ||
| 6 | + | import { Avatar, Badge, Empty, ErrorView, Loading, Row, Screen, SectionHeader } from "@/components/ui"; | |
| 7 | + | import { useChatSidebar } from "@/lib/chat"; | |
| 8 | + | import { useSignedIn } from "@/lib/session"; | |
| 9 | + | import { ago } from "@/lib/time"; | |
| 10 | + | import { useColors } from "@/theme"; | |
| 11 | + | ||
| 12 | + | /** Chat: the workspace's channels you are in, then your direct messages. */ | |
| 13 | + | export default function ChatList() { | |
| 14 | + | const { workspace, server } = useSignedIn(); | |
| 15 | + | const sidebar = useChatSidebar(); | |
| 16 | + | const colors = useColors(); | |
| 17 | + | ||
| 18 | + | const title = workspace?.name ?? workspace?.slug ?? "Chat"; | |
| 19 | + | if (!workspace) { | |
| 20 | + | return ( | |
| 21 | + | <Screen> | |
| 22 | + | <Stack.Screen options={{ title: "Chat" }} /> | |
| 23 | + | <Empty icon={MessagesSquare} title="No workspace yet" body="Create or join a workspace on g1t, then come back." /> | |
| 24 | + | </Screen> | |
| 25 | + | ); | |
| 26 | + | } | |
| 27 | + | ||
| 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 | + | return ( | |
| 35 | + | <Screen> | |
| 36 | + | <Stack.Screen options={{ title }} /> | |
| 37 | + | {sidebar.isPending ? ( | |
| 38 | + | <Loading /> | |
| 39 | + | ) : sidebar.error ? ( | |
| 40 | + | <ErrorView error={sidebar.error} retry={() => sidebar.refetch()} /> | |
| 41 | + | ) : ( | |
| 42 | + | <SectionList | |
| 43 | + | sections={sections} | |
| 44 | + | keyExtractor={(entry) => entry.channel.id} | |
| 45 | + | renderSectionHeader={({ section }) => <SectionHeader title={section.title} />} | |
| 46 | + | 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} | |
| 50 | + | /> | |
| 51 | + | )} | |
| 52 | + | </Screen> | |
| 53 | + | ); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | function Conversation({ entry, site }: { entry: ChatSidebarEntry; site: string }) { | |
| 57 | + | const colors = useColors(); | |
| 58 | + | const { channel } = entry; | |
| 59 | + | const dm = channel.kind === "dm"; | |
| 60 | + | 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; | |
| 69 | + | 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 | + | /> | |
| 80 | + | ); | |
| 81 | + | } |
| 1 | + | import { Stack, router, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import { CircleDot, GitPullRequest } from "lucide-react-native"; | |
| 3 | + | import { useState } from "react"; | |
| 4 | + | import { FlatList, RefreshControl, View } from "react-native"; | |
| 5 | + | ||
| 6 | + | import { Checks, IssueIcon, PullIcon } from "@/components/code"; | |
| 7 | + | import { Segments } from "@/components/segments"; | |
| 8 | + | import { Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui"; | |
| 9 | + | import { type ApiIssue, type ApiPull, useIssues, usePulls, useRepo } from "@/lib/code"; | |
| 10 | + | import { ago } from "@/lib/time"; | |
| 11 | + | import { space, useColors } from "@/theme"; | |
| 12 | + | ||
| 13 | + | type Tab = "pulls" | "issues"; | |
| 14 | + | type State = "open" | "closed"; | |
| 15 | + | ||
| 16 | + | /** A repository: its pull requests and issues. */ | |
| 17 | + | export default function Repository() { | |
| 18 | + | const { owner, repo } = useLocalSearchParams<{ owner: string; repo: string }>(); | |
| 19 | + | const [tab, setTab] = useState<Tab>("pulls"); | |
| 20 | + | const [state, setState] = useState<State>("open"); | |
| 21 | + | const details = useRepo(owner, repo); | |
| 22 | + | const pulls = usePulls(owner, repo, state); | |
| 23 | + | const issues = useIssues(owner, repo, state); | |
| 24 | + | const list = tab === "pulls" ? pulls : issues; | |
| 25 | + | const colors = useColors(); | |
| 26 | + | ||
| 27 | + | return ( | |
| 28 | + | <Screen> | |
| 29 | + | <Stack.Screen options={{ title: repo }} /> | |
| 30 | + | <View style={{ padding: space.lg, gap: space.md }}> | |
| 31 | + | <Text size="small" tone="muted"> | |
| 32 | + | {owner}/{repo} | |
| 33 | + | {details.data?.description ? ` · ${details.data.description}` : ""} | |
| 34 | + | </Text> | |
| 35 | + | <Segments | |
| 36 | + | value={tab} | |
| 37 | + | onChange={setTab} | |
| 38 | + | options={[ | |
| 39 | + | { value: "pulls", label: "Pull requests" }, | |
| 40 | + | { value: "issues", label: "Issues" }, | |
| 41 | + | ]} | |
| 42 | + | /> | |
| 43 | + | <Segments | |
| 44 | + | value={state} | |
| 45 | + | onChange={setState} | |
| 46 | + | options={[ | |
| 47 | + | { value: "open", label: "Open" }, | |
| 48 | + | { value: "closed", label: "Closed" }, | |
| 49 | + | ]} | |
| 50 | + | /> | |
| 51 | + | </View> | |
| 52 | + | {list.isPending ? ( | |
| 53 | + | <Loading /> | |
| 54 | + | ) : list.error ? ( | |
| 55 | + | <ErrorView error={list.error} retry={() => list.refetch()} /> | |
| 56 | + | ) : tab === "pulls" ? ( | |
| 57 | + | <FlatList | |
| 58 | + | data={pulls.data} | |
| 59 | + | keyExtractor={(pull) => pull.id} | |
| 60 | + | refreshControl={<RefreshControl refreshing={pulls.isRefetching} onRefresh={() => pulls.refetch()} tintColor={colors.accent} />} | |
| 61 | + | ListEmptyComponent={<Empty icon={GitPullRequest} title={`No ${state} pull requests`} />} | |
| 62 | + | renderItem={({ item }) => <PullRow pull={item} owner={owner} repo={repo} />} | |
| 63 | + | /> | |
| 64 | + | ) : ( | |
| 65 | + | <FlatList | |
| 66 | + | data={issues.data} | |
| 67 | + | keyExtractor={(issue) => issue.id} | |
| 68 | + | refreshControl={<RefreshControl refreshing={issues.isRefetching} onRefresh={() => issues.refetch()} tintColor={colors.accent} />} | |
| 69 | + | ListEmptyComponent={<Empty icon={CircleDot} title={`No ${state} issues`} />} | |
| 70 | + | renderItem={({ item }) => <IssueRow issue={item} owner={owner} repo={repo} />} | |
| 71 | + | /> | |
| 72 | + | )} | |
| 73 | + | </Screen> | |
| 74 | + | ); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | function PullRow({ pull, owner, repo }: { pull: ApiPull; owner: string; repo: string }) { | |
| 78 | + | const by = pull.author.display_username ?? pull.author.username; | |
| 79 | + | return ( | |
| 80 | + | <Row | |
| 81 | + | leading={<PullIcon pull={pull} />} | |
| 82 | + | title={pull.title} | |
| 83 | + | subtitle={`#${pull.number} · ${by}${pull.agent ? ` with ${pull.agent}` : ""} · ${ago(pull.updated_at)}`} | |
| 84 | + | trailing={<Checks status={pull.check_status} />} | |
| 85 | + | onPress={() => router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo, number: String(pull.number) } })} | |
| 86 | + | /> | |
| 87 | + | ); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | function IssueRow({ issue, owner, repo }: { issue: ApiIssue; owner: string; repo: string }) { | |
| 91 | + | const by = issue.author.display_username ?? issue.author.username; | |
| 92 | + | return ( | |
| 93 | + | <Row | |
| 94 | + | leading={<IssueIcon issue={issue} />} | |
| 95 | + | title={issue.title} | |
| 96 | + | subtitle={`#${issue.number} · ${by} · ${ago(issue.updated_at)}${issue.comment_count ? ` · ${issue.comment_count} comments` : ""}`} | |
| 97 | + | onPress={() => router.push({ pathname: "/code/[owner]/[repo]/issues/[number]", params: { owner, repo, number: String(issue.number) } })} | |
| 98 | + | /> | |
| 99 | + | ); | |
| 100 | + | } |
| 1 | + | import { Stack, router, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import * as WebBrowser from "expo-web-browser"; | |
| 3 | + | import { ExternalLink, Send } from "lucide-react-native"; | |
| 4 | + | import { useState } from "react"; | |
| 5 | + | import { View } from "react-native"; | |
| 6 | + | ||
| 7 | + | import { Body, IssueIcon, PullIcon, Timeline } from "@/components/code"; | |
| 8 | + | import { Button, ErrorView, Field, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| 9 | + | import { useComment, useIssue } from "@/lib/code"; | |
| 10 | + | import { siteUrl } from "@/lib/server"; | |
| 11 | + | import { useSignedIn } from "@/lib/session"; | |
| 12 | + | import { ago } from "@/lib/time"; | |
| 13 | + | import { space } from "@/theme"; | |
| 14 | + | ||
| 15 | + | /** An issue: what it asks for, the pull requests made for it, and its conversation. */ | |
| 16 | + | export default function Issue() { | |
| 17 | + | const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>(); | |
| 18 | + | const number = Number(params.number); | |
| 19 | + | const { server } = useSignedIn(); | |
| 20 | + | const issue = useIssue(params.owner, params.repo, number); | |
| 21 | + | const comment = useComment(params.owner, params.repo, number); | |
| 22 | + | const [draft, setDraft] = useState(""); | |
| 23 | + | ||
| 24 | + | if (issue.isPending || issue.error) { | |
| 25 | + | return ( | |
| 26 | + | <Screen> | |
| 27 | + | <Stack.Screen options={{ title: `#${number}` }} /> | |
| 28 | + | {issue.error ? <ErrorView error={issue.error} retry={() => issue.refetch()} /> : <Loading />} | |
| 29 | + | </Screen> | |
| 30 | + | ); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | const { issue: item, pulls, comments } = issue.data; | |
| 34 | + | const by = item.author.display_username ?? item.author.username; | |
| 35 | + | ||
| 36 | + | return ( | |
| 37 | + | <Screen scroll> | |
| 38 | + | <Stack.Screen options={{ title: `#${number}` }} /> | |
| 39 | + | <View style={{ gap: space.sm }}> | |
| 40 | + | <Text size="heading">{item.title}</Text> | |
| 41 | + | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}> | |
| 42 | + | <IssueIcon issue={item} /> | |
| 43 | + | <Text size="small" tone="muted"> | |
| 44 | + | {item.state === "open" ? "Open" : "Closed"} · {by} · {ago(item.created_at)} | |
| 45 | + | {item.labels.length ? ` · ${item.labels.join(", ")}` : ""} | |
| 46 | + | </Text> | |
| 47 | + | </View> | |
| 48 | + | </View> | |
| 49 | + | <Body text={item.body} /> | |
| 50 | + | {pulls.length ? ( | |
| 51 | + | <View style={{ marginHorizontal: -space.lg }}> | |
| 52 | + | <SectionHeader title="Pull requests" /> | |
| 53 | + | {pulls.map((pull) => ( | |
| 54 | + | <Row | |
| 55 | + | key={pull.id} | |
| 56 | + | leading={<PullIcon pull={pull} />} | |
| 57 | + | title={pull.title} | |
| 58 | + | subtitle={`#${pull.number} · ${pull.status}`} | |
| 59 | + | onPress={() => | |
| 60 | + | router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner: params.owner, repo: params.repo, number: String(pull.number) } }) | |
| 61 | + | } | |
| 62 | + | /> | |
| 63 | + | ))} | |
| 64 | + | </View> | |
| 65 | + | ) : null} | |
| 66 | + | <Timeline comments={comments} /> | |
| 67 | + | <View style={{ gap: space.sm }}> | |
| 68 | + | <Field label="Comment" placeholder="Add to the conversation" value={draft} onChangeText={setDraft} multiline /> | |
| 69 | + | <Button | |
| 70 | + | title="Comment" | |
| 71 | + | icon={Send} | |
| 72 | + | busy={comment.isPending} | |
| 73 | + | disabled={!draft.trim()} | |
| 74 | + | onPress={() => comment.mutate(draft.trim(), { onSuccess: () => setDraft("") })} | |
| 75 | + | /> | |
| 76 | + | {comment.error ? <Text tone="danger">{comment.error.message}</Text> : null} | |
| 77 | + | </View> | |
| 78 | + | <Button | |
| 79 | + | title="Open on g1t" | |
| 80 | + | variant="secondary" | |
| 81 | + | icon={ExternalLink} | |
| 82 | + | onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/issues/${number}`))} | |
| 83 | + | /> | |
| 84 | + | </Screen> | |
| 85 | + | ); | |
| 86 | + | } |
| 1 | + | import { Stack, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import * as WebBrowser from "expo-web-browser"; | |
| 3 | + | import { Check, ExternalLink, MessageSquareWarning } from "lucide-react-native"; | |
| 4 | + | import { useState } from "react"; | |
| 5 | + | import { View } from "react-native"; | |
| 6 | + | ||
| 7 | + | import { Body, Checks, Diff, StateBadge, Timeline } from "@/components/code"; | |
| 8 | + | import { Segments } from "@/components/segments"; | |
| 9 | + | import { Button, ErrorView, Field, Loading, Screen, Text } from "@/components/ui"; | |
| 10 | + | import { usePull, usePullChanges, useReview } from "@/lib/code"; | |
| 11 | + | import { siteUrl } from "@/lib/server"; | |
| 12 | + | import { useSignedIn } from "@/lib/session"; | |
| 13 | + | import { ago } from "@/lib/time"; | |
| 14 | + | import { space } from "@/theme"; | |
| 15 | + | ||
| 16 | + | /** | |
| 17 | + | * A pull request: what it is for, its checks, the conversation, and what | |
| 18 | + | * it changes; approve it or ask for changes from here. Merging stays on | |
| 19 | + | * the site for now, where its rules and the merge queue are shown. | |
| 20 | + | */ | |
| 21 | + | export default function PullRequest() { | |
| 22 | + | const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>(); | |
| 23 | + | const number = Number(params.number); | |
| 24 | + | const { server } = useSignedIn(); | |
| 25 | + | const [tab, setTab] = useState<"conversation" | "files">("conversation"); | |
| 26 | + | const pull = usePull(params.owner, params.repo, number); | |
| 27 | + | const changes = usePullChanges(params.owner, params.repo, number, tab === "files"); | |
| 28 | + | const review = useReview(params.owner, params.repo, number); | |
| 29 | + | const [note, setNote] = useState(""); | |
| 30 | + | ||
| 31 | + | const open = () => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/pull/${number}`)); | |
| 32 | + | ||
| 33 | + | if (pull.isPending) { | |
| 34 | + | return ( | |
| 35 | + | <Screen> | |
| 36 | + | <Stack.Screen options={{ title: `#${number}` }} /> | |
| 37 | + | <Loading /> | |
| 38 | + | </Screen> | |
| 39 | + | ); | |
| 40 | + | } | |
| 41 | + | if (pull.error) { | |
| 42 | + | return ( | |
| 43 | + | <Screen> | |
| 44 | + | <Stack.Screen options={{ title: `#${number}` }} /> | |
| 45 | + | <ErrorView error={pull.error} retry={() => pull.refetch()} /> | |
| 46 | + | </Screen> | |
| 47 | + | ); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | const { pull: pr, comments } = pull.data; | |
| 51 | + | const by = pr.author.display_username ?? pr.author.username; | |
| 52 | + | const reviewable = pr.status === "open"; | |
| 53 | + | ||
| 54 | + | return ( | |
| 55 | + | <Screen scroll> | |
| 56 | + | <Stack.Screen options={{ title: `#${number}` }} /> | |
| 57 | + | <View style={{ gap: space.sm }}> | |
| 58 | + | <Text size="heading">{pr.title}</Text> | |
| 59 | + | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center", flexWrap: "wrap" }}> | |
| 60 | + | <StateBadge pull={pr} /> | |
| 61 | + | <Checks status={pr.check_status} /> | |
| 62 | + | <Text size="small" tone="muted"> | |
| 63 | + | {by} | |
| 64 | + | {pr.agent ? ` with ${pr.agent}` : ""} · {pr.branch ?? "a fork"} → {pr.base} · {ago(pr.updated_at)} | |
| 65 | + | </Text> | |
| 66 | + | </View> | |
| 67 | + | </View> | |
| 68 | + | ||
| 69 | + | <Segments | |
| 70 | + | value={tab} | |
| 71 | + | onChange={setTab} | |
| 72 | + | options={[ | |
| 73 | + | { value: "conversation", label: "Conversation" }, | |
| 74 | + | { value: "files", label: `Files · ${pr.files.length}` }, | |
| 75 | + | ]} | |
| 76 | + | /> | |
| 77 | + | ||
| 78 | + | {tab === "conversation" ? ( | |
| 79 | + | <> | |
| 80 | + | <Body text={pr.body} /> | |
| 81 | + | <Timeline comments={comments} /> | |
| 82 | + | {reviewable ? ( | |
| 83 | + | <View style={{ gap: space.md }}> | |
| 84 | + | <Field label="Review" placeholder="Say what you think (optional to approve)" value={note} onChangeText={setNote} multiline /> | |
| 85 | + | <View style={{ flexDirection: "row", gap: space.sm }}> | |
| 86 | + | <View style={{ flex: 1 }}> | |
| 87 | + | <Button | |
| 88 | + | title="Approve" | |
| 89 | + | icon={Check} | |
| 90 | + | busy={review.isPending && review.variables?.verdict === "approve"} | |
| 91 | + | disabled={review.isPending} | |
| 92 | + | onPress={() => review.mutate({ verdict: "approve", body: note.trim() }, { onSuccess: () => setNote("") })} | |
| 93 | + | /> | |
| 94 | + | </View> | |
| 95 | + | <View style={{ flex: 1 }}> | |
| 96 | + | <Button | |
| 97 | + | title="Request changes" | |
| 98 | + | variant="secondary" | |
| 99 | + | icon={MessageSquareWarning} | |
| 100 | + | busy={review.isPending && review.variables?.verdict === "request_changes"} | |
| 101 | + | disabled={review.isPending || !note.trim()} | |
| 102 | + | onPress={() => review.mutate({ verdict: "request_changes", body: note.trim() }, { onSuccess: () => setNote("") })} | |
| 103 | + | /> | |
| 104 | + | </View> | |
| 105 | + | </View> | |
| 106 | + | {review.error ? <Text tone="danger">{review.error.message}</Text> : null} | |
| 107 | + | </View> | |
| 108 | + | ) : null} | |
| 109 | + | </> | |
| 110 | + | ) : changes.isPending ? ( | |
| 111 | + | <Loading /> | |
| 112 | + | ) : changes.error ? ( | |
| 113 | + | <ErrorView error={changes.error} retry={() => changes.refetch()} /> | |
| 114 | + | ) : ( | |
| 115 | + | <Diff changes={changes.data} /> | |
| 116 | + | )} | |
| 117 | + | ||
| 118 | + | <Button title="Open on g1t" variant="secondary" icon={ExternalLink} onPress={open} /> | |
| 119 | + | </Screen> | |
| 120 | + | ); | |
| 121 | + | } |
| 1 | + | import { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 1 | + | import { hasCodeAccess } from "@g1t/contracts"; | |
| 2 | + | import { Stack, router } from "expo-router"; | |
| 3 | + | import { BookLock, BookMarked, Code2 } from "lucide-react-native"; | |
| 4 | + | import { useState } from "react"; | |
| 5 | + | import { FlatList, RefreshControl, View } from "react-native"; | |
| 6 | + | ||
| 7 | + | import { Badge, Empty, ErrorView, Field, Loading, Row, Screen } from "@/components/ui"; | |
| 8 | + | import { useRepos } from "@/lib/code"; | |
| 9 | + | import { useSignedIn } from "@/lib/session"; | |
| 10 | + | import { ago } from "@/lib/time"; | |
| 11 | + | import { space, useColors } from "@/theme"; | |
| 12 | + | ||
| 13 | + | /** Code: the workspace's repositories you can see, newest first. */ | |
| 14 | + | export default function Repos() { | |
| 15 | + | const { workspace } = useSignedIn(); | |
| 16 | + | const [search, setSearch] = useState(""); | |
| 17 | + | const repos = useRepos(search.trim()); | |
| 18 | + | const colors = useColors(); | |
| 19 | + | ||
| 20 | + | if (workspace && !hasCodeAccess(workspace)) { | |
| 21 | + | return ( | |
| 22 | + | <Screen> | |
| 23 | + | <Stack.Screen options={{ title: "Code" }} /> | |
| 24 | + | <Empty icon={Code2} title="Code is not on for you here" body={`${workspace.name ?? workspace.slug} gives you Chat, Docs and agents. Ask an owner for Code.`} /> | |
| 25 | + | </Screen> | |
| 26 | + | ); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | return ( | |
| 30 | + | <Screen> | |
| 31 | + | <Stack.Screen options={{ title: "Code" }} /> | |
| 32 | + | <View style={{ padding: space.lg, paddingBottom: space.sm }}> | |
| 33 | + | <Field label="Find a repository" placeholder="Name or description" value={search} onChangeText={setSearch} returnKeyType="search" /> | |
| 34 | + | </View> | |
| 35 | + | {repos.isPending ? ( | |
| 36 | + | <Loading /> | |
| 37 | + | ) : repos.error ? ( | |
| 38 | + | <ErrorView error={repos.error} retry={() => repos.refetch()} /> | |
| 39 | + | ) : ( | |
| 40 | + | <FlatList | |
| 41 | + | data={repos.data} | |
| 42 | + | keyExtractor={(repo) => repo.id} | |
| 43 | + | refreshControl={<RefreshControl refreshing={repos.isRefetching} onRefresh={() => repos.refetch()} tintColor={colors.accent} />} | |
| 44 | + | ListEmptyComponent={<Empty icon={BookMarked} title={search ? "Nothing matches" : "No repositories yet"} />} | |
| 45 | + | renderItem={({ item }) => ( | |
| 46 | + | <Row | |
| 47 | + | leading={item.is_private ? <BookLock size={20} color={colors.muted} /> : <BookMarked size={20} color={colors.muted} />} | |
| 48 | + | title={`${item.namespace}/${item.name}`} | |
| 49 | + | subtitle={item.description ?? `Created ${ago(item.created_at)}`} | |
| 50 | + | trailing={item.archived_at ? <Badge label="Archived" tone="warn" /> : null} | |
| 51 | + | onPress={() => router.push({ pathname: "/code/[owner]/[repo]", params: { owner: item.namespace, repo: item.name } })} | |
| 52 | + | /> | |
| 53 | + | )} | |
| 54 | + | /> | |
| 55 | + | )} | |
| 56 | + | </Screen> | |
| 57 | + | ); | |
| 58 | + | } |
| 1 | + | import { CUSTOMIZE_SECTIONS, type CustomizeSection } from "@g1t/contracts/customize"; | |
| 2 | + | import { Redirect, Stack, useLocalSearchParams } from "expo-router"; | |
| 3 | + | import * as WebBrowser from "expo-web-browser"; | |
| 4 | + | import { ExternalLink } from "lucide-react-native"; | |
| 5 | + | import { SectionList, View } from "react-native"; | |
| 6 | + | ||
| 7 | + | import { Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| 8 | + | import { CUSTOMIZE, type CustomizeItem, customizeItems, customizePath } from "@/lib/customize"; | |
| 9 | + | import { siteUrl } from "@/lib/server"; | |
| 10 | + | import { useSignedIn } from "@/lib/session"; | |
| 11 | + | import { space } from "@/theme"; | |
| 12 | + | ||
| 13 | + | /** One part of Customize: its listings, grouped, each opening where it is set up. */ | |
| 14 | + | export default function CustomizeSectionScreen() { | |
| 15 | + | const { section } = useLocalSearchParams<{ section: string }>(); | |
| 16 | + | const { workspace, server } = useSignedIn(); | |
| 17 | + | if (!CUSTOMIZE_SECTIONS.includes(section as CustomizeSection)) return <Redirect href="/customize" />; | |
| 18 | + | const id = section as CustomizeSection; | |
| 19 | + | const slug = workspace?.slug ?? ""; | |
| 20 | + | const items = customizeItems(id, slug); | |
| 21 | + | const groups = [...new Set(items.map((item) => item.group))].map((group) => ({ title: group, data: items.filter((item) => item.group === group) })); | |
| 22 | + | const open = (path: string) => WebBrowser.openBrowserAsync(siteUrl(server, path)); | |
| 23 | + | ||
| 24 | + | return ( | |
| 25 | + | <Screen> | |
| 26 | + | <Stack.Screen options={{ title: CUSTOMIZE[id].title }} /> | |
| 27 | + | <SectionList | |
| 28 | + | sections={groups} | |
| 29 | + | keyExtractor={(item) => item.id} | |
| 30 | + | stickySectionHeadersEnabled={false} | |
| 31 | + | ListHeaderComponent={ | |
| 32 | + | <View style={{ padding: space.lg, gap: space.md }}> | |
| 33 | + | <Text tone="muted">{CUSTOMIZE[id].description}</Text> | |
| 34 | + | <Button title={`Manage ${CUSTOMIZE[id].title.toLowerCase()} on g1t`} variant="secondary" icon={ExternalLink} onPress={() => open(customizePath(id, slug))} /> | |
| 35 | + | </View> | |
| 36 | + | } | |
| 37 | + | renderSectionHeader={({ section: group }) => <SectionHeader title={group.title} />} | |
| 38 | + | renderItem={({ item }) => <Item item={item} onPress={() => open(item.sitePath)} />} | |
| 39 | + | /> | |
| 40 | + | </Screen> | |
| 41 | + | ); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | function Item({ item, onPress }: { item: CustomizeItem; onPress: () => void }) { | |
| 45 | + | const badge = item.status === "available" ? <Badge label="Available" tone="success" /> : item.status === "soon" ? <Badge label="Soon" /> : <Badge label="Built in" tone="accent" />; | |
| 46 | + | return <Row title={item.name} subtitle={item.description} trailing={badge} onPress={item.status === "soon" ? undefined : onPress} />; | |
| 47 | + | } |
| 1 | + | import { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 1 | + | import { CUSTOMIZE_SECTIONS } from "@g1t/contracts/customize"; | |
| 2 | + | import { Stack, router } from "expo-router"; | |
| 3 | + | import { Blocks, Plug, Sparkles } from "lucide-react-native"; | |
| 4 | + | import { View } from "react-native"; | |
| 5 | + | ||
| 6 | + | import { Row, Screen, Text } from "@/components/ui"; | |
| 7 | + | import { CUSTOMIZE, customizeItems } from "@/lib/customize"; | |
| 8 | + | import { useSignedIn } from "@/lib/session"; | |
| 9 | + | import { space, useColors } from "@/theme"; | |
| 10 | + | ||
| 11 | + | const ICONS = { skills: Sparkles, connectors: Plug, plugins: Blocks } as const; | |
| 12 | + | ||
| 13 | + | /** | |
| 14 | + | * Customize: what shapes how this workspace's agents work. Skills are what | |
| 15 | + | * they know how to do, Connectors what they can reach, Plugins what adds | |
| 16 | + | * to the workspace. It replaces the Marketplace. | |
| 17 | + | */ | |
| 18 | + | export default function Customize() { | |
| 19 | + | const { workspace } = useSignedIn(); | |
| 20 | + | const colors = useColors(); | |
| 21 | + | const slug = workspace?.slug ?? ""; | |
| 22 | + | return ( | |
| 23 | + | <Screen> | |
| 24 | + | <Stack.Screen options={{ title: "Customize" }} /> | |
| 25 | + | <View style={{ padding: space.lg }}> | |
| 26 | + | <Text tone="muted">Skills, connectors and plugins for {workspace?.name ?? slug}. Owners add them; anyone else asks an owner.</Text> | |
| 27 | + | </View> | |
| 28 | + | {CUSTOMIZE_SECTIONS.map((section) => { | |
| 29 | + | const Icon = ICONS[section]; | |
| 30 | + | const count = customizeItems(section, slug).length; | |
| 31 | + | return ( | |
| 32 | + | <Row | |
| 33 | + | key={section} | |
| 34 | + | leading={<Icon size={22} color={colors.accent} />} | |
| 35 | + | title={`${CUSTOMIZE[section].title} · ${count}`} | |
| 36 | + | subtitle={CUSTOMIZE[section].description} | |
| 37 | + | onPress={() => router.push({ pathname: "/customize/[section]", params: { section } })} | |
| 38 | + | /> | |
| 39 | + | ); | |
| 40 | + | })} | |
| 41 | + | </Screen> | |
| 42 | + | ); | |
| 43 | + | } |
| 1 | + | import { Redirect } from "expo-router"; | |
| 2 | + | ||
| 3 | + | /** The app opens on Chat. */ | |
| 4 | + | export default function Index() { | |
| 5 | + | return <Redirect href="/chat" />; | |
| 6 | + | } |
| 1 | + | import { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 1 | + | import 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 { routeFor, sitePathOf } from "@/lib/links"; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * Where a link that opens the app goes. `g1t://` links name a site path, | |
| 5 | + | * as on the desktop; the app shows the ones it has screens for and opens | |
| 6 | + | * the rest on the site (app/open.tsx). Android App Links and iOS | |
| 7 | + | * Universal Links for https://g1t.sh come through here too once the site | |
| 8 | + | * publishes assetlinks.json and apple-app-site-association. | |
| 9 | + | */ | |
| 10 | + | export function redirectSystemPath({ path }: { path: string; initial: boolean }): string { | |
| 11 | + | try { | |
| 12 | + | const sitePath = sitePathOf(path) ?? sitePathOfWeb(path) ?? path; | |
| 13 | + | const route = routeFor(sitePath); | |
| 14 | + | return route ?? `/open?path=${encodeURIComponent(sitePath)}`; | |
| 15 | + | } catch { | |
| 16 | + | return "/"; | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | function sitePathOfWeb(link: string): string | null { | |
| 21 | + | if (!/^https?:\/\//i.test(link)) return null; | |
| 22 | + | const url = new URL(link); | |
| 23 | + | return url.pathname + url.search; | |
| 24 | + | } |
| 1 | + | import { Link, Stack } from "expo-router"; | |
| 2 | + | import { CircleHelp } from "lucide-react-native"; | |
| 3 | + | ||
| 4 | + | import { Empty, Screen, Text } from "@/components/ui"; | |
| 5 | + | ||
| 6 | + | export default function NotFound() { | |
| 7 | + | return ( | |
| 8 | + | <Screen safe> | |
| 9 | + | <Stack.Screen options={{ headerShown: false }} /> | |
| 10 | + | <Empty | |
| 11 | + | icon={CircleHelp} | |
| 12 | + | title="Nothing here" | |
| 13 | + | body="The app has no screen for this link." | |
| 14 | + | action={ | |
| 15 | + | <Link href="/" replace> | |
| 16 | + | <Text tone="accent">Go to Chat</Text> | |
| 17 | + | </Link> | |
| 18 | + | } | |
| 19 | + | /> | |
| 20 | + | </Screen> | |
| 21 | + | ); | |
| 22 | + | } |
| 1 | + | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | |
| 2 | + | import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from "expo-router"; | |
| 3 | + | import * as SplashScreen from "expo-splash-screen"; | |
| 4 | + | import { StatusBar } from "expo-status-bar"; | |
| 5 | + | import { useEffect, useState } from "react"; | |
| 6 | + | import { useColorScheme } from "react-native"; | |
| 7 | + | ||
| 8 | + | import { ApiError } from "@/lib/client"; | |
| 9 | + | import { SessionProvider, useSession } from "@/lib/session"; | |
| 10 | + | import { useColors } from "@/theme"; | |
| 11 | + | ||
| 12 | + | void SplashScreen.preventAutoHideAsync(); | |
| 13 | + | ||
| 14 | + | export default function RootLayout() { | |
| 15 | + | const [queries] = useState( | |
| 16 | + | () => | |
| 17 | + | new QueryClient({ | |
| 18 | + | defaultOptions: { | |
| 19 | + | queries: { | |
| 20 | + | staleTime: 30_000, | |
| 21 | + | // Signed out, not found and refused are answers, not hiccups. | |
| 22 | + | retry: (count, error) => !(error instanceof ApiError && error.code !== "offline") && count < 2, | |
| 23 | + | }, | |
| 24 | + | }, | |
| 25 | + | }), | |
| 26 | + | ); | |
| 27 | + | return ( | |
| 28 | + | <QueryClientProvider client={queries}> | |
| 29 | + | <SessionProvider> | |
| 30 | + | <Navigation /> | |
| 31 | + | </SessionProvider> | |
| 32 | + | </QueryClientProvider> | |
| 33 | + | ); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | function Navigation() { | |
| 37 | + | const { state } = useSession(); | |
| 38 | + | const scheme = useColorScheme(); | |
| 39 | + | const colors = useColors(); | |
| 40 | + | const base = scheme === "light" ? DefaultTheme : DarkTheme; | |
| 41 | + | const theme = { | |
| 42 | + | ...base, | |
| 43 | + | colors: { ...base.colors, primary: colors.accent, background: colors.bg, card: colors.surface, text: colors.fg, border: colors.line }, | |
| 44 | + | }; | |
| 45 | + | ||
| 46 | + | useEffect(() => { | |
| 47 | + | if (state.status !== "loading") void SplashScreen.hideAsync(); | |
| 48 | + | }, [state.status]); | |
| 49 | + | ||
| 50 | + | if (state.status === "loading") return null; | |
| 51 | + | const signedIn = state.status === "signed-in"; | |
| 52 | + | ||
| 53 | + | return ( | |
| 54 | + | <ThemeProvider value={theme}> | |
| 55 | + | <StatusBar style={scheme === "light" ? "dark" : "light"} /> | |
| 56 | + | <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}> | |
| 57 | + | <Stack.Protected guard={signedIn}> | |
| 58 | + | <Stack.Screen name="(app)" /> | |
| 59 | + | </Stack.Protected> | |
| 60 | + | <Stack.Protected guard={!signedIn}> | |
| 61 | + | <Stack.Screen name="sign-in" /> | |
| 62 | + | </Stack.Protected> | |
| 63 | + | <Stack.Screen name="oauth/callback" /> | |
| 64 | + | <Stack.Screen name="open" /> | |
| 65 | + | </Stack> | |
| 66 | + | </ThemeProvider> | |
| 67 | + | ); | |
| 68 | + | } |
| 1 | + | import { Redirect } from "expo-router"; | |
| 2 | + | ||
| 3 | + | import { Loading, Screen } from "@/components/ui"; | |
| 4 | + | import { useSession } from "@/lib/session"; | |
| 5 | + | ||
| 6 | + | /** | |
| 7 | + | * Where the site sends the person back after they approve the app | |
| 8 | + | * (`g1t://oauth/callback`). The sign-in screen's browser session reads | |
| 9 | + | * the code from the address itself (lib/oauth.ts); this screen is only | |
| 10 | + | * what shows if the link opens the app on its own, and it moves on. | |
| 11 | + | */ | |
| 12 | + | export default function OAuthCallback() { | |
| 13 | + | const { state } = useSession(); | |
| 14 | + | if (state.status === "loading") { | |
| 15 | + | return ( | |
| 16 | + | <Screen> | |
| 17 | + | <Loading /> | |
| 18 | + | </Screen> | |
| 19 | + | ); | |
| 20 | + | } | |
| 21 | + | return <Redirect href={state.status === "signed-in" ? "/chat" : "/sign-in"} />; | |
| 22 | + | } |
| 1 | + | import { router, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import * as WebBrowser from "expo-web-browser"; | |
| 3 | + | import { useEffect } from "react"; | |
| 4 | + | ||
| 5 | + | import { Loading, Screen } from "@/components/ui"; | |
| 6 | + | import { useSession } from "@/lib/session"; | |
| 7 | + | import { siteUrl } from "@/lib/server"; | |
| 8 | + | ||
| 9 | + | /** | |
| 10 | + | * A link to a page the app has no screen for: it opens on the site, in | |
| 11 | + | * the system's browser, and the app goes back to where it was. | |
| 12 | + | */ | |
| 13 | + | export default function Open() { | |
| 14 | + | const { path } = useLocalSearchParams<{ path?: string }>(); | |
| 15 | + | const { state } = useSession(); | |
| 16 | + | ||
| 17 | + | useEffect(() => { | |
| 18 | + | const server = state.server; | |
| 19 | + | if (!server) return; | |
| 20 | + | const safe = path && path.startsWith("/") ? path : "/"; | |
| 21 | + | void WebBrowser.openBrowserAsync(siteUrl(server, safe)).finally(() => { | |
| 22 | + | if (router.canGoBack()) router.back(); | |
| 23 | + | else router.replace("/"); | |
| 24 | + | }); | |
| 25 | + | }, [path, state.server]); | |
| 26 | + | ||
| 27 | + | return ( | |
| 28 | + | <Screen> | |
| 29 | + | <Loading /> | |
| 30 | + | </Screen> | |
| 31 | + | ); | |
| 32 | + | } |
| 1 | + | import { KeyRound, LogIn, Server as ServerIcon } from "lucide-react-native"; | |
| 2 | + | import { useState } from "react"; | |
| 3 | + | import { KeyboardAvoidingView, Platform, View } from "react-native"; | |
| 4 | + | ||
| 5 | + | import { Button, Field, Screen, Text } from "@/components/ui"; | |
| 6 | + | import { G1tMark } from "@/components/mark"; | |
| 7 | + | import { SignInCancelled } from "@/lib/oauth"; | |
| 8 | + | import { HOSTED, isHosted, parseServer } from "@/lib/server"; | |
| 9 | + | import { useSession } from "@/lib/session"; | |
| 10 | + | import { space } from "@/theme"; | |
| 11 | + | ||
| 12 | + | /** | |
| 13 | + | * Signing in. The usual way is **Sign in with g1t**: the site opens in the | |
| 14 | + | * system's browser, the person approves the app, and it comes back signed | |
| 15 | + | * in (lib/oauth.ts). **Use an access token** takes a personal token | |
| 16 | + | * instead. **Server** points the app at a self-hosted g1t. | |
| 17 | + | */ | |
| 18 | + | export default function SignIn() { | |
| 19 | + | const { state, signIn, signInWithToken, changeServer } = useSession(); | |
| 20 | + | const server = state.server ?? HOSTED; | |
| 21 | + | const [mode, setMode] = useState<"oauth" | "token" | "server">("oauth"); | |
| 22 | + | const [busy, setBusy] = useState(false); | |
| 23 | + | const [error, setError] = useState<string | null>(null); | |
| 24 | + | const [token, setToken] = useState(""); | |
| 25 | + | const [site, setSite] = useState(server.site); | |
| 26 | + | const [api, setApi] = useState(isHosted(server) ? "" : server.api); | |
| 27 | + | ||
| 28 | + | const run = async (work: () => Promise<void>) => { | |
| 29 | + | setBusy(true); | |
| 30 | + | setError(null); | |
| 31 | + | try { | |
| 32 | + | await work(); | |
| 33 | + | } catch (failure) { | |
| 34 | + | if (!(failure instanceof SignInCancelled)) setError(failure instanceof Error ? failure.message : "Sign-in failed."); | |
| 35 | + | } finally { | |
| 36 | + | setBusy(false); | |
| 37 | + | } | |
| 38 | + | }; | |
| 39 | + | ||
| 40 | + | return ( | |
| 41 | + | <Screen safe> | |
| 42 | + | <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : undefined} style={{ flex: 1 }}> | |
| 43 | + | <View style={{ flex: 1, padding: space.xl, justifyContent: "center", gap: space.xl }}> | |
| 44 | + | <View style={{ gap: space.md }}> | |
| 45 | + | <G1tMark size={44} /> | |
| 46 | + | <Text size="title">Sign in to g1t</Text> | |
| 47 | + | <Text tone="muted">Chat with your team and its agents, and review and merge what they make.</Text> | |
| 48 | + | </View> | |
| 49 | + | ||
| 50 | + | {mode === "oauth" ? ( | |
| 51 | + | <View style={{ gap: space.md }}> | |
| 52 | + | <Button title="Sign in with g1t" icon={LogIn} busy={busy} onPress={() => run(signIn)} /> | |
| 53 | + | <Text size="small" tone="faint"> | |
| 54 | + | {new URL(server.site).host} opens in your browser to approve the app. Your password never reaches it. | |
| 55 | + | </Text> | |
| 56 | + | </View> | |
| 57 | + | ) : null} | |
| 58 | + | ||
| 59 | + | {mode === "token" ? ( | |
| 60 | + | <View style={{ gap: space.md }}> | |
| 61 | + | <Field | |
| 62 | + | label="Access token" | |
| 63 | + | placeholder="g1t_…" | |
| 64 | + | value={token} | |
| 65 | + | onChangeText={setToken} | |
| 66 | + | secureTextEntry | |
| 67 | + | hint="Make one in Settings → Access tokens. For Chat, turn on “Use the website as you”." | |
| 68 | + | /> | |
| 69 | + | <Button title="Sign in" busy={busy} disabled={!token.trim()} onPress={() => run(() => signInWithToken(token))} /> | |
| 70 | + | </View> | |
| 71 | + | ) : null} | |
| 72 | + | ||
| 73 | + | {mode === "server" ? ( | |
| 74 | + | <View style={{ gap: space.md }}> | |
| 75 | + | <Field label="Site" placeholder="g1t.example.com" value={site} onChangeText={setSite} keyboardType="url" /> | |
| 76 | + | <Field | |
| 77 | + | label="API (optional)" | |
| 78 | + | placeholder={`api.${site ? site.replace(/^https?:\/\//, "") : "g1t.example.com"}`} | |
| 79 | + | value={api} | |
| 80 | + | onChangeText={setApi} | |
| 81 | + | keyboardType="url" | |
| 82 | + | hint="Left empty, the API is api. in front of the site's address." | |
| 83 | + | /> | |
| 84 | + | <Button | |
| 85 | + | title="Use this server" | |
| 86 | + | busy={busy} | |
| 87 | + | onPress={() => | |
| 88 | + | run(async () => { | |
| 89 | + | const next = parseServer(site, api); | |
| 90 | + | if (!next) throw new Error("That is not a web address. It looks like https://g1t.example.com."); | |
| 91 | + | await changeServer(next); | |
| 92 | + | setMode("oauth"); | |
| 93 | + | }) | |
| 94 | + | } | |
| 95 | + | /> | |
| 96 | + | <Button | |
| 97 | + | title="Use g1t.sh" | |
| 98 | + | variant="secondary" | |
| 99 | + | onPress={() => { | |
| 100 | + | setSite(HOSTED.site); | |
| 101 | + | setApi(""); | |
| 102 | + | void run(async () => { | |
| 103 | + | await changeServer(HOSTED); | |
| 104 | + | setMode("oauth"); | |
| 105 | + | }); | |
| 106 | + | }} | |
| 107 | + | /> | |
| 108 | + | </View> | |
| 109 | + | ) : null} | |
| 110 | + | ||
| 111 | + | {error ? <Text tone="danger">{error}</Text> : null} | |
| 112 | + | ||
| 113 | + | <View style={{ gap: space.sm }}> | |
| 114 | + | {mode !== "oauth" ? <Button title="Sign in with g1t instead" variant="secondary" icon={LogIn} onPress={() => setMode("oauth")} /> : null} | |
| 115 | + | {mode !== "token" ? <Button title="Use an access token" variant="secondary" icon={KeyRound} onPress={() => setMode("token")} /> : null} | |
| 116 | + | {mode !== "server" ? ( | |
| 117 | + | <Button title={isHosted(server) ? "Server: g1t.sh" : `Server: ${new URL(server.site).host}`} variant="secondary" icon={ServerIcon} onPress={() => setMode("server")} /> | |
| 118 | + | ) : null} | |
| 119 | + | </View> | |
| 120 | + | </View> | |
| 121 | + | </KeyboardAvoidingView> | |
| 122 | + | </Screen> | |
| 123 | + | ); | |
| 124 | + | } |
| 1 | + | import { CircleCheck, CircleDot, CircleX, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Loader } from "lucide-react-native"; | |
| 2 | + | import { ScrollView, StyleSheet, View } from "react-native"; | |
| 3 | + | ||
| 4 | + | import { Badge, Text } from "@/components/ui"; | |
| 5 | + | import type { ApiChanges, ApiComment, ApiIssue, ApiPull, CheckStatus } from "@/lib/code"; | |
| 6 | + | import { ago } from "@/lib/time"; | |
| 7 | + | import { fonts, radius, space, useColors } from "@/theme"; | |
| 8 | + | ||
| 9 | + | /** A pull request's state, as an icon in its colour. */ | |
| 10 | + | export function PullIcon({ pull }: { pull: Pick<ApiPull, "status"> }) { | |
| 11 | + | const colors = useColors(); | |
| 12 | + | if (pull.status === "merged") return <GitMerge size={20} color={colors.merged} />; | |
| 13 | + | if (pull.status === "closed") return <GitPullRequestClosed size={20} color={colors.danger} />; | |
| 14 | + | if (pull.status === "draft") return <GitPullRequestDraft size={20} color={colors.muted} />; | |
| 15 | + | return <GitPullRequest size={20} color={colors.success} />; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export function IssueIcon({ issue }: { issue: Pick<ApiIssue, "state" | "reason"> }) { | |
| 19 | + | const colors = useColors(); | |
| 20 | + | if (issue.state === "open") return <CircleDot size={20} color={colors.success} />; | |
| 21 | + | return issue.reason === "not_planned" ? <CircleX size={20} color={colors.muted} /> : <CircleCheck size={20} color={colors.merged} />; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | export function Checks({ status }: { status: CheckStatus }) { | |
| 25 | + | const colors = useColors(); | |
| 26 | + | if (status === "passed") return <CircleCheck size={16} color={colors.success} accessibilityLabel="Checks passed" />; | |
| 27 | + | if (status === "failed" || status === "errored") return <CircleX size={16} color={colors.danger} accessibilityLabel="Checks failed" />; | |
| 28 | + | if (status === "queued" || status === "running") return <Loader size={16} color={colors.warn} accessibilityLabel="Checks running" />; | |
| 29 | + | return null; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | export function StateBadge({ pull }: { pull: Pick<ApiPull, "status"> }) { | |
| 33 | + | const tone = pull.status === "merged" ? "merged" : pull.status === "closed" ? "danger" : pull.status === "open" ? "success" : "neutral"; | |
| 34 | + | return <Badge label={pull.status[0].toUpperCase() + pull.status.slice(1)} tone={tone} />; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /** Markdown, shown as it was written until the app renders it. */ | |
| 38 | + | export function Body({ text }: { text: string | null }) { | |
| 39 | + | const colors = useColors(); | |
| 40 | + | if (!text?.trim()) { | |
| 41 | + | return ( | |
| 42 | + | <Text tone="faint" style={{ fontStyle: "italic" }}> | |
| 43 | + | No description. | |
| 44 | + | </Text> | |
| 45 | + | ); | |
| 46 | + | } | |
| 47 | + | return ( | |
| 48 | + | <View style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface }]}> | |
| 49 | + | <Text selectable>{text}</Text> | |
| 50 | + | </View> | |
| 51 | + | ); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /** The conversation under an issue or pull request: comments, reviews and what happened. */ | |
| 55 | + | export function Timeline({ comments }: { comments: ApiComment[] }) { | |
| 56 | + | const colors = useColors(); | |
| 57 | + | if (comments.length === 0) return null; | |
| 58 | + | return ( | |
| 59 | + | <View style={{ gap: space.md }}> | |
| 60 | + | {comments.map((comment) => | |
| 61 | + | comment.kind === "event" ? ( | |
| 62 | + | <Text key={comment.id} size="small" tone="muted"> | |
| 63 | + | {comment.author.display_username ?? comment.author.username} {comment.body} · {ago(comment.created_at)} | |
| 64 | + | </Text> | |
| 65 | + | ) : ( | |
| 66 | + | <View key={comment.id} style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface, gap: space.xs }]}> | |
| 67 | + | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}> | |
| 68 | + | <Text style={{ fontWeight: "600" }}>{comment.author.display_username ?? comment.author.username}</Text> | |
| 69 | + | {comment.author.kind === "agent" ? <Badge label="Agent" tone="accent" /> : null} | |
| 70 | + | {comment.verdict ? <Badge label={comment.verdict.replace(/_/g, " ")} tone={comment.verdict === "approve" ? "success" : "warn"} /> : null} | |
| 71 | + | <Text size="small" tone="faint"> | |
| 72 | + | {ago(comment.created_at)} | |
| 73 | + | </Text> | |
| 74 | + | </View> | |
| 75 | + | {comment.path ? ( | |
| 76 | + | <Text size="mono" tone="muted"> | |
| 77 | + | {comment.path} | |
| 78 | + | {comment.line ? `:${comment.line}` : ""} | |
| 79 | + | </Text> | |
| 80 | + | ) : null} | |
| 81 | + | <Text selectable>{comment.body}</Text> | |
| 82 | + | </View> | |
| 83 | + | ), | |
| 84 | + | )} | |
| 85 | + | </View> | |
| 86 | + | ); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /** What a pull request changes, file by file, as unified hunks. */ | |
| 90 | + | export function Diff({ changes }: { changes: ApiChanges }) { | |
| 91 | + | const colors = useColors(); | |
| 92 | + | return ( | |
| 93 | + | <View style={{ gap: space.lg }}> | |
| 94 | + | {changes.files.map((file) => ( | |
| 95 | + | <View key={file.path} style={[styles.file, { borderColor: colors.line }]}> | |
| 96 | + | <View style={[styles.fileHeader, { backgroundColor: colors.raised, borderBottomColor: colors.line }]}> | |
| 97 | + | <Text size="mono" style={{ flex: 1 }} numberOfLines={1}> | |
| 98 | + | {file.path} | |
| 99 | + | </Text> | |
| 100 | + | <Text size="mono" tone="success"> | |
| 101 | + | +{file.additions} | |
| 102 | + | </Text> | |
| 103 | + | <Text size="mono" tone="danger"> | |
| 104 | + | −{file.deletions} | |
| 105 | + | </Text> | |
| 106 | + | </View> | |
| 107 | + | {file.binary ? ( | |
| 108 | + | <Text tone="muted" size="small" style={{ padding: space.md }}> | |
| 109 | + | A binary file. | |
| 110 | + | </Text> | |
| 111 | + | ) : ( | |
| 112 | + | <ScrollView horizontal> | |
| 113 | + | <View> | |
| 114 | + | {(file.hunks ?? []).flatMap((hunk, index) => | |
| 115 | + | hunk.lines.map((line, at) => ( | |
| 116 | + | <Text | |
| 117 | + | key={`${index}-${at}`} | |
| 118 | + | style={{ | |
| 119 | + | fontFamily: fonts.mono, | |
| 120 | + | fontSize: 12, | |
| 121 | + | lineHeight: 18, | |
| 122 | + | paddingHorizontal: space.sm, | |
| 123 | + | color: line.kind === "add" ? colors.success : line.kind === "delete" ? colors.danger : colors.fgSoft, | |
| 124 | + | backgroundColor: line.kind === "add" ? colors.successLow : "transparent", | |
| 125 | + | }} | |
| 126 | + | > | |
| 127 | + | {line.kind === "add" ? "+ " : line.kind === "delete" ? "− " : " "} | |
| 128 | + | {line.text} | |
| 129 | + | </Text> | |
| 130 | + | )), | |
| 131 | + | )} | |
| 132 | + | </View> | |
| 133 | + | </ScrollView> | |
| 134 | + | )} | |
| 135 | + | </View> | |
| 136 | + | ))} | |
| 137 | + | {changes.truncated ? ( | |
| 138 | + | <Text tone="muted" size="small"> | |
| 139 | + | Too much changed to show here. Open it on g1t for the rest. | |
| 140 | + | </Text> | |
| 141 | + | ) : null} | |
| 142 | + | </View> | |
| 143 | + | ); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | const styles = StyleSheet.create({ | |
| 147 | + | box: { borderWidth: 1, borderRadius: radius.md, padding: space.md }, | |
| 148 | + | file: { borderWidth: 1, borderRadius: radius.md, overflow: "hidden" }, | |
| 149 | + | fileHeader: { flexDirection: "row", gap: space.sm, padding: space.sm, borderBottomWidth: StyleSheet.hairlineWidth }, | |
| 150 | + | }); |
| 1 | + | import Svg, { Rect } from "react-native-svg"; | |
| 2 | + | ||
| 3 | + | import { useColors } from "@/theme"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * The g1t mark, as packages/theme/mark.svg draws it: pixels on an 80 by | |
| 7 | + | * 80 grid, in the accent. | |
| 8 | + | */ | |
| 9 | + | const PIXELS: [number, number][] = [ | |
| 10 | + | [36, 6], | |
| 11 | + | [26, 16], | |
| 12 | + | [36, 16], | |
| 13 | + | [16, 26], | |
| 14 | + | [36, 26], | |
| 15 | + | [36, 36], | |
| 16 | + | [36, 46], | |
| 17 | + | [36, 56], | |
| 18 | + | [16, 66], | |
| 19 | + | [26, 66], | |
| 20 | + | [36, 66], | |
| 21 | + | [46, 66], | |
| 22 | + | [56, 66], | |
| 23 | + | ]; | |
| 24 | + | ||
| 25 | + | export function G1tMark({ size = 32 }: { size?: number }) { | |
| 26 | + | const colors = useColors(); | |
| 27 | + | return ( | |
| 28 | + | <Svg width={size} height={size} viewBox="0 0 80 80" accessibilityLabel="g1t"> | |
| 29 | + | {PIXELS.map(([x, y]) => ( | |
| 30 | + | <Rect key={`${x}-${y}`} x={x} y={y} width={8} height={8} fill={colors.accent} /> | |
| 31 | + | ))} | |
| 32 | + | </Svg> | |
| 33 | + | ); | |
| 34 | + | } |
| 1 | + | import { Pressable, StyleSheet, View } from "react-native"; | |
| 2 | + | ||
| 3 | + | import { Text } from "@/components/ui"; | |
| 4 | + | import { radius, space, useColors } from "@/theme"; | |
| 5 | + | ||
| 6 | + | /** A row of choices, one picked: the site's tabs, on a phone. */ | |
| 7 | + | export function Segments<T extends string>({ options, value, onChange }: { options: { value: T; label: string }[]; value: T; onChange: (value: T) => void }) { | |
| 8 | + | const colors = useColors(); | |
| 9 | + | return ( | |
| 10 | + | <View style={[styles.row, { backgroundColor: colors.raised }]}> | |
| 11 | + | {options.map((option) => { | |
| 12 | + | const on = option.value === value; | |
| 13 | + | return ( | |
| 14 | + | <Pressable | |
| 15 | + | key={option.value} | |
| 16 | + | accessibilityRole="tab" | |
| 17 | + | accessibilityState={{ selected: on }} | |
| 18 | + | onPress={() => onChange(option.value)} | |
| 19 | + | style={[styles.option, on && { backgroundColor: colors.surface, borderColor: colors.lineStrong }]} | |
| 20 | + | > | |
| 21 | + | <Text size="small" style={{ fontWeight: "600", color: on ? colors.fg : colors.muted }}> | |
| 22 | + | {option.label} | |
| 23 | + | </Text> | |
| 24 | + | </Pressable> | |
| 25 | + | ); | |
| 26 | + | })} | |
| 27 | + | </View> | |
| 28 | + | ); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | const styles = StyleSheet.create({ | |
| 32 | + | row: { flexDirection: "row", borderRadius: radius.md, padding: 3, gap: 3 }, | |
| 33 | + | option: { flex: 1, alignItems: "center", paddingVertical: space.sm, borderRadius: radius.sm, borderWidth: 1, borderColor: "transparent" }, | |
| 34 | + | }); |
| 1 | + | import { Stack } from "expo-router"; | |
| 2 | + | ||
| 3 | + | import { useColors } from "@/theme"; | |
| 4 | + | ||
| 5 | + | /** A tab's stack of screens, with g1t's header. */ | |
| 6 | + | export function TabStack() { | |
| 7 | + | const colors = useColors(); | |
| 8 | + | return ( | |
| 9 | + | <Stack | |
| 10 | + | screenOptions={{ | |
| 11 | + | headerStyle: { backgroundColor: colors.surface }, | |
| 12 | + | headerTintColor: colors.fg, | |
| 13 | + | headerTitleStyle: { color: colors.fg }, | |
| 14 | + | headerShadowVisible: false, | |
| 15 | + | contentStyle: { backgroundColor: colors.bg }, | |
| 16 | + | }} | |
| 17 | + | /> | |
| 18 | + | ); | |
| 19 | + | } |
| 1 | + | import { Image } from "expo-image"; | |
| 2 | + | import type { LucideIcon } from "lucide-react-native"; | |
| 3 | + | import { ChevronRight } from "lucide-react-native"; | |
| 4 | + | import type { ReactNode } from "react"; | |
| 5 | + | import { | |
| 6 | + | ActivityIndicator, | |
| 7 | + | Pressable, | |
| 8 | + | type PressableProps, | |
| 9 | + | ScrollView, | |
| 10 | + | StyleSheet, | |
| 11 | + | Text as NativeText, | |
| 12 | + | type TextProps, | |
| 13 | + | TextInput, | |
| 14 | + | type TextInputProps, | |
| 15 | + | View, | |
| 16 | + | type ViewProps, | |
| 17 | + | } from "react-native"; | |
| 18 | + | import { SafeAreaView } from "react-native-safe-area-context"; | |
| 19 | + | ||
| 20 | + | import { ApiError } from "@/lib/client"; | |
| 21 | + | import { fonts, radius, space, useColors } from "@/theme"; | |
| 22 | + | ||
| 23 | + | /** | |
| 24 | + | * The app's building blocks, themed with g1t's tokens. As on the site | |
| 25 | + | * (apps/web/app/components/ui), a screen is built from these, and a look | |
| 26 | + | * changes through a component's variant, not a style at the call site. | |
| 27 | + | */ | |
| 28 | + | ||
| 29 | + | type Tone = "default" | "muted" | "faint" | "accent" | "danger" | "success"; | |
| 30 | + | type Size = "title" | "heading" | "body" | "small" | "mono"; | |
| 31 | + | ||
| 32 | + | const SIZES: Record<Size, { fontSize: number; lineHeight: number; fontWeight?: "600" | "700" }> = { | |
| 33 | + | title: { fontSize: 26, lineHeight: 32, fontWeight: "700" }, | |
| 34 | + | heading: { fontSize: 17, lineHeight: 22, fontWeight: "600" }, | |
| 35 | + | body: { fontSize: 15, lineHeight: 21 }, | |
| 36 | + | small: { fontSize: 13, lineHeight: 18 }, | |
| 37 | + | mono: { fontSize: 12, lineHeight: 17 }, | |
| 38 | + | }; | |
| 39 | + | ||
| 40 | + | export function Text({ size = "body", tone = "default", style, ...props }: TextProps & { size?: Size; tone?: Tone }) { | |
| 41 | + | const colors = useColors(); | |
| 42 | + | const color = { default: colors.fg, muted: colors.muted, faint: colors.faint, accent: colors.accent, danger: colors.danger, success: colors.success }[tone]; | |
| 43 | + | return <NativeText style={[SIZES[size], size === "mono" && { fontFamily: fonts.mono }, { color }, style]} {...props} />; | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /** A screen's background, inside the safe area when it has no header above it. */ | |
| 47 | + | export function Screen({ children, scroll, safe, style, ...props }: ViewProps & { scroll?: boolean; safe?: boolean }) { | |
| 48 | + | const colors = useColors(); | |
| 49 | + | const body = scroll ? ( | |
| 50 | + | <ScrollView contentContainerStyle={[styles.padded, style]} keyboardShouldPersistTaps="handled"> | |
| 51 | + | {children} | |
| 52 | + | </ScrollView> | |
| 53 | + | ) : ( | |
| 54 | + | <View style={[{ flex: 1 }, style]} {...props}> | |
| 55 | + | {children} | |
| 56 | + | </View> | |
| 57 | + | ); | |
| 58 | + | return safe ? ( | |
| 59 | + | <SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }}>{body}</SafeAreaView> | |
| 60 | + | ) : ( | |
| 61 | + | <View style={{ flex: 1, backgroundColor: colors.bg }}>{body}</View> | |
| 62 | + | ); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | export function Button({ | |
| 66 | + | title, | |
| 67 | + | variant = "primary", | |
| 68 | + | busy, | |
| 69 | + | disabled, | |
| 70 | + | icon: Icon, | |
| 71 | + | ...props | |
| 72 | + | }: PressableProps & { title: string; variant?: "primary" | "secondary" | "danger"; busy?: boolean; icon?: LucideIcon }) { | |
| 73 | + | const colors = useColors(); | |
| 74 | + | const look = { | |
| 75 | + | primary: { bg: colors.accent, fg: colors.onAccent, border: colors.accent }, | |
| 76 | + | secondary: { bg: colors.surface, fg: colors.fg, border: colors.lineStrong }, | |
| 77 | + | danger: { bg: colors.surface, fg: colors.danger, border: colors.lineStrong }, | |
| 78 | + | }[variant]; | |
| 79 | + | const off = disabled || busy; | |
| 80 | + | return ( | |
| 81 | + | <Pressable | |
| 82 | + | accessibilityRole="button" | |
| 83 | + | disabled={off} | |
| 84 | + | style={({ pressed }) => [styles.button, { backgroundColor: look.bg, borderColor: look.border, opacity: off ? 0.55 : pressed ? 0.8 : 1 }]} | |
| 85 | + | {...props} | |
| 86 | + | > | |
| 87 | + | {busy ? <ActivityIndicator color={look.fg} /> : Icon ? <Icon size={18} color={look.fg} /> : null} | |
| 88 | + | <Text style={{ color: look.fg, fontWeight: "600" }}>{title}</Text> | |
| 89 | + | </Pressable> | |
| 90 | + | ); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | export function Field({ label, hint, ...props }: TextInputProps & { label: string; hint?: string }) { | |
| 94 | + | const colors = useColors(); | |
| 95 | + | return ( | |
| 96 | + | <View style={{ gap: space.xs }}> | |
| 97 | + | <Text size="small" tone="muted"> | |
| 98 | + | {label} | |
| 99 | + | </Text> | |
| 100 | + | <TextInput | |
| 101 | + | placeholderTextColor={colors.faint} | |
| 102 | + | autoCapitalize="none" | |
| 103 | + | autoCorrect={false} | |
| 104 | + | style={[styles.input, { color: colors.fg, backgroundColor: colors.surface, borderColor: colors.lineStrong }]} | |
| 105 | + | {...props} | |
| 106 | + | /> | |
| 107 | + | {hint ? ( | |
| 108 | + | <Text size="small" tone="faint"> | |
| 109 | + | {hint} | |
| 110 | + | </Text> | |
| 111 | + | ) : null} | |
| 112 | + | </View> | |
| 113 | + | ); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /** A row in a list: what it is, a line under it, and what is at its end. */ | |
| 117 | + | export function Row({ | |
| 118 | + | title, | |
| 119 | + | subtitle, | |
| 120 | + | leading, | |
| 121 | + | trailing, | |
| 122 | + | chevron = true, | |
| 123 | + | onPress, | |
| 124 | + | }: { | |
| 125 | + | title: ReactNode; | |
| 126 | + | subtitle?: ReactNode; | |
| 127 | + | leading?: ReactNode; | |
| 128 | + | trailing?: ReactNode; | |
| 129 | + | chevron?: boolean; | |
| 130 | + | onPress?: () => void; | |
| 131 | + | }) { | |
| 132 | + | const colors = useColors(); | |
| 133 | + | return ( | |
| 134 | + | <Pressable | |
| 135 | + | onPress={onPress} | |
| 136 | + | disabled={!onPress} | |
| 137 | + | style={({ pressed }) => [styles.row, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]} | |
| 138 | + | > | |
| 139 | + | {leading} | |
| 140 | + | <View style={{ flex: 1, gap: 2 }}> | |
| 141 | + | {typeof title === "string" ? ( | |
| 142 | + | <Text numberOfLines={1} style={{ fontWeight: "600" }}> | |
| 143 | + | {title} | |
| 144 | + | </Text> | |
| 145 | + | ) : ( | |
| 146 | + | title | |
| 147 | + | )} | |
| 148 | + | {typeof subtitle === "string" ? ( | |
| 149 | + | <Text size="small" tone="muted" numberOfLines={2}> | |
| 150 | + | {subtitle} | |
| 151 | + | </Text> | |
| 152 | + | ) : ( | |
| 153 | + | subtitle | |
| 154 | + | )} | |
| 155 | + | </View> | |
| 156 | + | {trailing} | |
| 157 | + | {onPress && chevron ? <ChevronRight size={18} color={colors.faint} /> : null} | |
| 158 | + | </Pressable> | |
| 159 | + | ); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | export function SectionHeader({ title }: { title: string }) { | |
| 163 | + | const colors = useColors(); | |
| 164 | + | return ( | |
| 165 | + | <View style={[styles.sectionHeader, { backgroundColor: colors.bg }]}> | |
| 166 | + | <Text size="small" tone="muted" style={{ fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.6 }}> | |
| 167 | + | {title} | |
| 168 | + | </Text> | |
| 169 | + | </View> | |
| 170 | + | ); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | type BadgeTone = "neutral" | "accent" | "success" | "danger" | "merged" | "warn"; | |
| 174 | + | ||
| 175 | + | export function Badge({ label, tone = "neutral" }: { label: string; tone?: BadgeTone }) { | |
| 176 | + | const colors = useColors(); | |
| 177 | + | const fg = { neutral: colors.muted, accent: colors.accent, success: colors.success, danger: colors.danger, merged: colors.merged, warn: colors.warn }[tone]; | |
| 178 | + | return ( | |
| 179 | + | <View style={[styles.badge, { borderColor: fg }]}> | |
| 180 | + | <Text size="small" style={{ color: fg, fontWeight: "600" }}> | |
| 181 | + | {label} | |
| 182 | + | </Text> | |
| 183 | + | </View> | |
| 184 | + | ); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /** | |
| 188 | + | * A person's or an agent's picture: the uploaded one when there is one, | |
| 189 | + | * else their initial. Agents carry a marker, as everywhere on g1t. The | |
| 190 | + | * site draws an agent's own bot face (components/agent-face.tsx); bringing | |
| 191 | + | * that here is to come. | |
| 192 | + | */ | |
| 193 | + | export function Avatar({ name, uri, agent, size = 32 }: { name: string; uri?: string | null; agent?: boolean; size?: number }) { | |
| 194 | + | const colors = useColors(); | |
| 195 | + | const corner = agent ? radius.sm : size / 2; | |
| 196 | + | return ( | |
| 197 | + | <View accessibilityLabel={agent ? `${name}, agent` : name} style={{ width: size, height: size }}> | |
| 198 | + | {uri ? ( | |
| 199 | + | <Image source={{ uri }} style={{ width: size, height: size, borderRadius: corner }} /> | |
| 200 | + | ) : ( | |
| 201 | + | <View style={{ width: size, height: size, borderRadius: corner, backgroundColor: colors.accentLow, alignItems: "center", justifyContent: "center" }}> | |
| 202 | + | <Text style={{ color: colors.accent, fontWeight: "700", fontSize: size * 0.42, lineHeight: size * 0.5 }}>{name.slice(0, 1).toUpperCase()}</Text> | |
| 203 | + | </View> | |
| 204 | + | )} | |
| 205 | + | {agent ? <View style={[styles.agentMarker, { backgroundColor: colors.accent, borderColor: colors.bg }]} /> : null} | |
| 206 | + | </View> | |
| 207 | + | ); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | export function Loading() { | |
| 211 | + | const colors = useColors(); | |
| 212 | + | return ( | |
| 213 | + | <View style={styles.center}> | |
| 214 | + | <ActivityIndicator color={colors.accent} /> | |
| 215 | + | </View> | |
| 216 | + | ); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | export function Empty({ icon: Icon, title, body, action }: { icon?: LucideIcon; title: string; body?: string; action?: ReactNode }) { | |
| 220 | + | const colors = useColors(); | |
| 221 | + | return ( | |
| 222 | + | <View style={[styles.center, { gap: space.sm, padding: space.xl }]}> | |
| 223 | + | {Icon ? <Icon size={28} color={colors.faint} /> : null} | |
| 224 | + | <Text size="heading" style={{ textAlign: "center" }}> | |
| 225 | + | {title} | |
| 226 | + | </Text> | |
| 227 | + | {body ? ( | |
| 228 | + | <Text tone="muted" style={{ textAlign: "center" }}> | |
| 229 | + | {body} | |
| 230 | + | </Text> | |
| 231 | + | ) : null} | |
| 232 | + | {action} | |
| 233 | + | </View> | |
| 234 | + | ); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /** What went wrong, in words, with a way to try again. */ | |
| 238 | + | export function ErrorView({ error, retry }: { error: unknown; retry?: () => void }) { | |
| 239 | + | const message = error instanceof Error ? error.message : "Something went wrong."; | |
| 240 | + | const title = error instanceof ApiError && error.code === "website_refused" ? "Chat needs another sign-in" : "Could not load this"; | |
| 241 | + | return <Empty title={title} body={message} action={retry ? <Button title="Try again" variant="secondary" onPress={retry} /> : null} />; | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | const styles = StyleSheet.create({ | |
| 245 | + | padded: { padding: space.lg, gap: space.lg }, | |
| 246 | + | button: { | |
| 247 | + | minHeight: 46, | |
| 248 | + | borderRadius: radius.md, | |
| 249 | + | borderWidth: 1, | |
| 250 | + | paddingHorizontal: space.lg, | |
| 251 | + | flexDirection: "row", | |
| 252 | + | alignItems: "center", | |
| 253 | + | justifyContent: "center", | |
| 254 | + | gap: space.sm, | |
| 255 | + | }, | |
| 256 | + | input: { minHeight: 46, borderRadius: radius.md, borderWidth: 1, paddingHorizontal: space.md, fontSize: 15 }, | |
| 257 | + | row: { | |
| 258 | + | flexDirection: "row", | |
| 259 | + | alignItems: "center", | |
| 260 | + | gap: space.md, | |
| 261 | + | paddingHorizontal: space.lg, | |
| 262 | + | paddingVertical: space.md, | |
| 263 | + | borderBottomWidth: StyleSheet.hairlineWidth, | |
| 264 | + | }, | |
| 265 | + | sectionHeader: { paddingHorizontal: space.lg, paddingTop: space.lg, paddingBottom: space.sm }, | |
| 266 | + | badge: { borderWidth: 1, borderRadius: radius.full, paddingHorizontal: space.sm, paddingVertical: 1 }, | |
| 267 | + | agentMarker: { position: "absolute", right: -2, bottom: -2, width: 10, height: 10, borderRadius: 3, borderWidth: 2 }, | |
| 268 | + | center: { flex: 1, alignItems: "center", justifyContent: "center" }, | |
| 269 | + | }); |
| 1 | + | import type { 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"; | |
| 4 | + | ||
| 5 | + | import type { Client } from "./client"; | |
| 6 | + | import { socketUrl } from "./server"; | |
| 7 | + | import { useSignedIn } from "./session"; | |
| 8 | + | ||
| 9 | + | /** | |
| 10 | + | * Chat, through the site's routes, as the site's own Chat pages use them | |
| 11 | + | * (apps/web/app/routes/workspace/chat/api.ts): | |
| 12 | + | * | |
| 13 | + | * - `GET /<workspace>/-/chat/api`: the sidebar. | |
| 14 | + | * - `GET /<workspace>/-/chat/api?channel=<id>&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). | |
| 19 | + | */ | |
| 20 | + | ||
| 21 | + | const chatPath = (workspace: string) => `/${encodeURIComponent(workspace)}/-/chat/api`; | |
| 22 | + | ||
| 23 | + | export function useChatSidebar() { | |
| 24 | + | const { client, workspace } = useSignedIn(); | |
| 25 | + | const slug = workspace?.slug ?? ""; | |
| 26 | + | return useQuery({ | |
| 27 | + | queryKey: ["chat", slug, "sidebar"], | |
| 28 | + | queryFn: () => client.site<ChatSidebar>(chatPath(slug)), | |
| 29 | + | enabled: !!slug, | |
| 30 | + | }); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | export function useMessages(channelId: string, thread?: string | null) { | |
| 34 | + | const { client, workspace } = useSignedIn(); | |
| 35 | + | const slug = workspace?.slug ?? ""; | |
| 36 | + | return useInfiniteQuery({ | |
| 37 | + | queryKey: ["chat", slug, "messages", channelId, thread ?? null], | |
| 38 | + | initialPageParam: null as string | null, | |
| 39 | + | queryFn: ({ pageParam }) => { | |
| 40 | + | const query = new URLSearchParams({ channel: channelId, limit: "60" }); | |
| 41 | + | if (thread) query.set("thread", thread); | |
| 42 | + | if (pageParam) query.set("before", pageParam); | |
| 43 | + | return client.site<MessagePage>(`${chatPath(slug)}?${query}`); | |
| 44 | + | }, | |
| 45 | + | getNextPageParam: (page) => page.older, | |
| 46 | + | enabled: !!slug && !!channelId, | |
| 47 | + | }); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /** Sends a message; it appears through the live socket, or on the next read. */ | |
| 51 | + | export function usePostMessage(channelId: string, thread?: string | null) { | |
| 52 | + | const { client, workspace } = useSignedIn(); | |
| 53 | + | const queries = useQueryClient(); | |
| 54 | + | 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), | |
| 62 | + | }); | |
| 63 | + | } | |
| 64 | + | ||
| 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 | + | } | |
| 71 | + | ||
| 72 | + | type Pages = InfiniteData<MessagePage, string | null>; | |
| 73 | + | ||
| 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] }; | |
| 81 | + | }); | |
| 82 | + | void queries.invalidateQueries({ queryKey: ["chat", workspace, "sidebar"] }); | |
| 83 | + | } | |
| 84 | + | ||
| 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, | |
| 90 | + | ); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | export type LiveStatus = "connecting" | "live" | "offline"; | |
| 94 | + | ||
| 95 | + | /** | |
| 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. | |
| 99 | + | */ | |
| 100 | + | export function useChannelLive(channelId: string): LiveStatus { | |
| 101 | + | const { client, workspace } = useSignedIn(); | |
| 102 | + | const queries = useQueryClient(); | |
| 103 | + | const slug = workspace?.slug ?? ""; | |
| 104 | + | const [status, setStatus] = useState<LiveStatus>("connecting"); | |
| 105 | + | ||
| 106 | + | useEffect(() => { | |
| 107 | + | 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] }); | |
| 144 | + | } | |
| 145 | + | }; | |
| 146 | + | socket.onclose = () => { | |
| 147 | + | if (!closed) again(); | |
| 148 | + | }; | |
| 149 | + | }; | |
| 150 | + | ||
| 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 | + | }; | |
| 157 | + | ||
| 158 | + | void open(); | |
| 159 | + | return () => { | |
| 160 | + | closed = true; | |
| 161 | + | if (retry) clearTimeout(retry); | |
| 162 | + | socket?.close(); | |
| 163 | + | }; | |
| 164 | + | }, [client, queries, slug, channelId]); | |
| 165 | + | ||
| 166 | + | return status; | |
| 167 | + | } |
| 1 | + | import { type Credentials, saveCredentials } from "./credentials"; | |
| 2 | + | import { SignedOut, refresh } from "./oauth"; | |
| 3 | + | import type { Server } from "./server"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * Every request the app makes, with its credentials: `api` for the REST | |
| 7 | + | * API (api.g1t.sh), `site` for the site's own JSON routes, which Chat | |
| 8 | + | * lives behind (`/<workspace>/-/chat/api`). Both take | |
| 9 | + | * `Authorization: Bearer g1t_…`. | |
| 10 | + | * | |
| 11 | + | * An OAuth access token is refreshed a minute before it expires, and on a | |
| 12 | + | * 401 once; requests that need a refresh at the same time share it, | |
| 13 | + | * because a refresh token works once. | |
| 14 | + | */ | |
| 15 | + | ||
| 16 | + | export type ErrorCode = | |
| 17 | + | /** Signed out: the session is over and the app returns to sign-in. */ | |
| 18 | + | | "signed_out" | |
| 19 | + | /** | |
| 20 | + | * The site refused the token: an OAuth token, or a personal one without | |
| 21 | + | * "Use the website as you". The API still works; Chat does not. | |
| 22 | + | */ | |
| 23 | + | | "website_refused" | |
| 24 | + | | "not_found" | |
| 25 | + | | "forbidden" | |
| 26 | + | | "offline" | |
| 27 | + | | "failed"; | |
| 28 | + | ||
| 29 | + | export class ApiError extends Error { | |
| 30 | + | constructor( | |
| 31 | + | readonly code: ErrorCode, | |
| 32 | + | message: string, | |
| 33 | + | readonly status = 0, | |
| 34 | + | ) { | |
| 35 | + | super(message); | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | export type Session = { | |
| 40 | + | server: Server; | |
| 41 | + | credentials: Credentials; | |
| 42 | + | /** Told when credentials change (a refresh) or end (signed out). */ | |
| 43 | + | onCredentials: (next: Credentials | null) => void; | |
| 44 | + | }; | |
| 45 | + | ||
| 46 | + | const REFRESH_EARLY_MS = 60_000; | |
| 47 | + | ||
| 48 | + | export class Client { | |
| 49 | + | private refreshing: Promise<Credentials> | null = null; | |
| 50 | + | ||
| 51 | + | constructor(private session: Session) {} | |
| 52 | + | ||
| 53 | + | get server(): Server { | |
| 54 | + | return this.session.server; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | get credentials(): Credentials { | |
| 58 | + | return this.session.credentials; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /** A REST API call: `client.api<Repo[]>("/repos")`. */ | |
| 62 | + | api<T>(path: string, init?: RequestInit): Promise<T> { | |
| 63 | + | return this.json<T>(this.session.server.api + path, init, "api"); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /** | |
| 67 | + | * One of the site's JSON routes. They answer `{ ok, value }` or | |
| 68 | + | * `{ ok: false, error }`, the services' own shape, which this unwraps. | |
| 69 | + | */ | |
| 70 | + | async site<T>(path: string, init?: RequestInit): Promise<T> { | |
| 71 | + | const answer = await this.json<{ ok: true; value: T } | { ok: false; error: { code: string; message: string } }>( | |
| 72 | + | this.session.server.site + path, | |
| 73 | + | init, | |
| 74 | + | "site", | |
| 75 | + | ); | |
| 76 | + | if (!answer.ok) { | |
| 77 | + | const code: ErrorCode = answer.error.code === "not_found" ? "not_found" : answer.error.code === "forbidden" ? "forbidden" : "failed"; | |
| 78 | + | throw new ApiError(code, answer.error.message); | |
| 79 | + | } | |
| 80 | + | return answer.value; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /** The current access token, refreshed first when it is about to expire. */ | |
| 84 | + | async token(): Promise<string> { | |
| 85 | + | const credentials = this.session.credentials; | |
| 86 | + | if (credentials.kind === "oauth" && credentials.expiresAt - REFRESH_EARLY_MS < Date.now()) { | |
| 87 | + | return (await this.refreshOnce()).accessToken; | |
| 88 | + | } | |
| 89 | + | return credentials.accessToken; | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | private refreshOnce(): Promise<Credentials> { | |
| 93 | + | const credentials = this.session.credentials; | |
| 94 | + | if (credentials.kind !== "oauth") return Promise.resolve(credentials); | |
| 95 | + | this.refreshing ??= refresh(this.session.server, credentials) | |
| 96 | + | .then(async (next) => { | |
| 97 | + | this.session = { ...this.session, credentials: next }; | |
| 98 | + | await saveCredentials(next); | |
| 99 | + | this.session.onCredentials(next); | |
| 100 | + | return next; | |
| 101 | + | }) | |
| 102 | + | .catch((error) => { | |
| 103 | + | if (error instanceof SignedOut) this.session.onCredentials(null); | |
| 104 | + | throw error instanceof SignedOut ? new ApiError("signed_out", error.message, 401) : error; | |
| 105 | + | }) | |
| 106 | + | .finally(() => { | |
| 107 | + | this.refreshing = null; | |
| 108 | + | }); | |
| 109 | + | return this.refreshing; | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | private async json<T>(url: string, init: RequestInit | undefined, target: "api" | "site", retried = false): Promise<T> { | |
| 113 | + | const token = await this.token(); | |
| 114 | + | let response: Response; | |
| 115 | + | try { | |
| 116 | + | response = await fetch(url, { | |
| 117 | + | ...init, | |
| 118 | + | headers: { | |
| 119 | + | accept: "application/json", | |
| 120 | + | ...(init?.body ? { "content-type": "application/json" } : {}), | |
| 121 | + | ...init?.headers, | |
| 122 | + | authorization: `Bearer ${token}`, | |
| 123 | + | }, | |
| 124 | + | }); | |
| 125 | + | } catch { | |
| 126 | + | throw new ApiError("offline", "Could not reach g1t. Check your connection."); | |
| 127 | + | } | |
| 128 | + | if (response.status === 401) { | |
| 129 | + | // An expired or revoked OAuth token gets one refresh; the site refusing | |
| 130 | + | // a token it will never take is not that. | |
| 131 | + | const refusedBySite = target === "site" && (await this.siteRefusesToken(response)); | |
| 132 | + | if (refusedBySite) { | |
| 133 | + | throw new ApiError( | |
| 134 | + | "website_refused", | |
| 135 | + | this.session.credentials.kind === "oauth" | |
| 136 | + | ? "Chat is not open to the app's sign-in on this g1t yet. Sign in with an access token that can use the website, from You → Sign in another way." | |
| 137 | + | : "This access token cannot use the website. Turn on “Use the website as you” for it in Settings → Access tokens.", | |
| 138 | + | 401, | |
| 139 | + | ); | |
| 140 | + | } | |
| 141 | + | if (!retried && this.session.credentials.kind === "oauth") { | |
| 142 | + | await this.refreshOnce(); | |
| 143 | + | return this.json<T>(url, init, target, true); | |
| 144 | + | } | |
| 145 | + | this.session.onCredentials(null); | |
| 146 | + | throw new ApiError("signed_out", "You were signed out. Sign in again.", 401); | |
| 147 | + | } | |
| 148 | + | if (response.status === 404) throw new ApiError("not_found", "Not found.", 404); | |
| 149 | + | if (response.status === 403) throw new ApiError("forbidden", await messageOf(response, "You do not have access to this."), 403); | |
| 150 | + | if (!response.ok) throw new ApiError("failed", await messageOf(response, `g1t answered ${response.status}.`), response.status); | |
| 151 | + | return (await response.json()) as T; | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /** | |
| 155 | + | * Whether a 401 from the site is it refusing the token itself (its | |
| 156 | + | * `WWW-Authenticate` names `invalid_token`; apps/web lib/website-token.ts). | |
| 157 | + | * The token may still be good for the API: check before signing out. | |
| 158 | + | */ | |
| 159 | + | private async siteRefusesToken(response: Response): Promise<boolean> { | |
| 160 | + | if (!/invalid_token/.test(response.headers.get("www-authenticate") ?? "")) return false; | |
| 161 | + | try { | |
| 162 | + | const check = await fetch(`${this.session.server.api}/user`, { | |
| 163 | + | headers: { authorization: `Bearer ${this.session.credentials.accessToken}` }, | |
| 164 | + | }); | |
| 165 | + | return check.ok; | |
| 166 | + | } catch { | |
| 167 | + | return false; | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | async function messageOf(response: Response, fallback: string): Promise<string> { | |
| 173 | + | try { | |
| 174 | + | const body = (await response.clone().json()) as { message?: string; error?: { message?: string }; error_description?: string }; | |
| 175 | + | return body.message ?? body.error?.message ?? body.error_description ?? fallback; | |
| 176 | + | } catch { | |
| 177 | + | const text = await response.text().catch(() => ""); | |
| 178 | + | return text.trim().slice(0, 300) || fallback; | |
| 179 | + | } | |
| 180 | + | } |
| 1 | + | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 2 | + | ||
| 3 | + | import { useSignedIn } from "./session"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * Code, through the REST API (docs.g1t.sh/reference/api/). The API | |
| 7 | + | * answers in snake_case, so these are its shapes, not the camelCase ones | |
| 8 | + | * in @g1t/contracts that the services use among themselves. Only the | |
| 9 | + | * fields the app shows are named. | |
| 10 | + | */ | |
| 11 | + | ||
| 12 | + | export type ApiUser = { id: string; username: string; display_username?: string; kind?: "user" | "workspace" | "agent" | "system" }; | |
| 13 | + | ||
| 14 | + | export type ApiRepo = { | |
| 15 | + | id: string; | |
| 16 | + | namespace: string; | |
| 17 | + | name: string; | |
| 18 | + | description: string | null; | |
| 19 | + | is_private: boolean; | |
| 20 | + | default_branch: string; | |
| 21 | + | created_at: string; | |
| 22 | + | archived_at?: string | null; | |
| 23 | + | }; | |
| 24 | + | ||
| 25 | + | export type ApiIssue = { | |
| 26 | + | id: string; | |
| 27 | + | number: number; | |
| 28 | + | title: string; | |
| 29 | + | body: string; | |
| 30 | + | labels: string[]; | |
| 31 | + | state: "open" | "closed"; | |
| 32 | + | reason: "completed" | "not_planned" | null; | |
| 33 | + | author: ApiUser; | |
| 34 | + | created_at: string; | |
| 35 | + | updated_at: string; | |
| 36 | + | comment_count: number; | |
| 37 | + | pull_count: number; | |
| 38 | + | }; | |
| 39 | + | ||
| 40 | + | export type CheckStatus = "queued" | "running" | "passed" | "failed" | "errored" | null; | |
| 41 | + | ||
| 42 | + | export type ApiPull = { | |
| 43 | + | id: string; | |
| 44 | + | number: number; | |
| 45 | + | issue: number | null; | |
| 46 | + | title: string; | |
| 47 | + | body: string | null; | |
| 48 | + | agent: string; | |
| 49 | + | status: "draft" | "open" | "merged" | "closed"; | |
| 50 | + | branch: string | null; | |
| 51 | + | base: string; | |
| 52 | + | check_status: CheckStatus; | |
| 53 | + | files: { path: string; additions: number; deletions: number }[]; | |
| 54 | + | reviewers: string[]; | |
| 55 | + | labels: string[]; | |
| 56 | + | author: ApiUser; | |
| 57 | + | created_at: string; | |
| 58 | + | updated_at: string; | |
| 59 | + | merged_at: string | null; | |
| 60 | + | }; | |
| 61 | + | ||
| 62 | + | export type ApiComment = { | |
| 63 | + | id: string; | |
| 64 | + | kind: "comment" | "event"; | |
| 65 | + | author: ApiUser; | |
| 66 | + | body: string; | |
| 67 | + | path: string | null; | |
| 68 | + | line: number | null; | |
| 69 | + | verdict: string | null; | |
| 70 | + | created_at: string; | |
| 71 | + | }; | |
| 72 | + | ||
| 73 | + | export type ApiChanges = { | |
| 74 | + | base: string; | |
| 75 | + | head: string; | |
| 76 | + | truncated: boolean; | |
| 77 | + | files: { | |
| 78 | + | path: string; | |
| 79 | + | status: "added" | "modified" | "deleted"; | |
| 80 | + | additions: number; | |
| 81 | + | deletions: number; | |
| 82 | + | binary: boolean; | |
| 83 | + | hunks?: { lines: { kind: "context" | "add" | "delete"; old: number | null; new: number | null; text: string }[] }[]; | |
| 84 | + | }[]; | |
| 85 | + | }; | |
| 86 | + | ||
| 87 | + | const repoPath = (owner: string, name: string) => `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; | |
| 88 | + | ||
| 89 | + | /** Repositories you can see, newest first (at most 50), in the workspace chosen. */ | |
| 90 | + | export function useRepos(search: string) { | |
| 91 | + | const { client, workspace } = useSignedIn(); | |
| 92 | + | return useQuery({ | |
| 93 | + | queryKey: ["code", "repos", search], | |
| 94 | + | queryFn: () => client.api<ApiRepo[]>(`/repos${search ? `?q=${encodeURIComponent(search)}` : ""}`), | |
| 95 | + | select: (repos) => (workspace ? repos.filter((repo) => repo.namespace.toLowerCase() === workspace.slug.toLowerCase()) : repos), | |
| 96 | + | }); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | export function useRepo(owner: string, name: string) { | |
| 100 | + | const { client } = useSignedIn(); | |
| 101 | + | return useQuery({ queryKey: ["code", "repo", owner, name], queryFn: () => client.api<ApiRepo>(repoPath(owner, name)) }); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | export function usePulls(owner: string, name: string, state: "open" | "closed" | "all") { | |
| 105 | + | const { client } = useSignedIn(); | |
| 106 | + | return useQuery({ | |
| 107 | + | queryKey: ["code", "pulls", owner, name, state], | |
| 108 | + | queryFn: () => client.api<ApiPull[]>(`${repoPath(owner, name)}/pulls?state=${state}`), | |
| 109 | + | }); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | export function useIssues(owner: string, name: string, state: "open" | "closed" | "all") { | |
| 113 | + | const { client } = useSignedIn(); | |
| 114 | + | return useQuery({ | |
| 115 | + | queryKey: ["code", "issues", owner, name, state], | |
| 116 | + | queryFn: () => client.api<ApiIssue[]>(`${repoPath(owner, name)}/issues?state=${state}`), | |
| 117 | + | }); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | export function usePull(owner: string, name: string, number: number) { | |
| 121 | + | const { client } = useSignedIn(); | |
| 122 | + | return useQuery({ | |
| 123 | + | queryKey: ["code", "pull", owner, name, number], | |
| 124 | + | queryFn: () => | |
| 125 | + | client.api<{ pull: ApiPull; issue: ApiIssue | null; comments: ApiComment[] }>(`${repoPath(owner, name)}/pulls/${number}`), | |
| 126 | + | }); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | export function usePullChanges(owner: string, name: string, number: number, enabled: boolean) { | |
| 130 | + | const { client } = useSignedIn(); | |
| 131 | + | return useQuery({ | |
| 132 | + | queryKey: ["code", "pull-changes", owner, name, number], | |
| 133 | + | queryFn: () => client.api<ApiChanges>(`${repoPath(owner, name)}/pulls/${number}/changes`), | |
| 134 | + | enabled, | |
| 135 | + | }); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | export function useIssue(owner: string, name: string, number: number) { | |
| 139 | + | const { client } = useSignedIn(); | |
| 140 | + | return useQuery({ | |
| 141 | + | queryKey: ["code", "issue", owner, name, number], | |
| 142 | + | queryFn: () => | |
| 143 | + | client.api<{ issue: ApiIssue; pulls: ApiPull[]; comments: ApiComment[] }>(`${repoPath(owner, name)}/issues/${number}`), | |
| 144 | + | }); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /** Comments on an issue (`POST /repos/{owner}/{name}/issues/{number}/comments`). */ | |
| 148 | + | export function useComment(owner: string, name: string, number: number) { | |
| 149 | + | const { client } = useSignedIn(); | |
| 150 | + | const queries = useQueryClient(); | |
| 151 | + | return useMutation({ | |
| 152 | + | mutationFn: (body: string) => | |
| 153 | + | client.api<ApiComment>(`${repoPath(owner, name)}/issues/${number}/comments`, { method: "POST", body: JSON.stringify({ body }) }), | |
| 154 | + | onSuccess: () => queries.invalidateQueries({ queryKey: ["code", "issue", owner, name, number] }), | |
| 155 | + | }); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** Reviews a pull request (`POST /repos/{owner}/{name}/pulls/{number}/reviews`). */ | |
| 159 | + | export function useReview(owner: string, name: string, number: number) { | |
| 160 | + | const { client } = useSignedIn(); | |
| 161 | + | const queries = useQueryClient(); | |
| 162 | + | return useMutation({ | |
| 163 | + | mutationFn: (review: { verdict: "approve" | "request_changes"; body: string }) => | |
| 164 | + | client.api<ApiComment>(`${repoPath(owner, name)}/pulls/${number}/reviews`, { method: "POST", body: JSON.stringify(review) }), | |
| 165 | + | onSuccess: () => queries.invalidateQueries({ queryKey: ["code", "pull", owner, name, number] }), | |
| 166 | + | }); | |
| 167 | + | } |
| 1 | + | import * as SecureStore from "expo-secure-store"; | |
| 2 | + | ||
| 3 | + | import { HOSTED, type Server } from "./server"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * What the app keeps between launches, in the phone's keystore (Android | |
| 7 | + | * Keystore, the iOS keychain): the server, and how it is signed in. | |
| 8 | + | * | |
| 9 | + | * Two ways to be signed in: | |
| 10 | + | * - `oauth`: the app's own sign-in (lib/oauth.ts). An access token that | |
| 11 | + | * lasts 30 days and a refresh token that works once and returns the | |
| 12 | + | * next pair, as every OAuth client of g1t's gets. | |
| 13 | + | * - `token`: a personal access token pasted in, for a g1t whose OAuth | |
| 14 | + | * tokens do not yet open Chat (see the README) or for testing. One with | |
| 15 | + | * "Use the website as you" turned on opens everything the app shows. | |
| 16 | + | */ | |
| 17 | + | export type Credentials = | |
| 18 | + | | { | |
| 19 | + | kind: "oauth"; | |
| 20 | + | accessToken: string; | |
| 21 | + | refreshToken: string; | |
| 22 | + | /** Epoch milliseconds. */ | |
| 23 | + | expiresAt: number; | |
| 24 | + | /** The scopes the person granted, or `*` for full access. */ | |
| 25 | + | scope: string; | |
| 26 | + | } | |
| 27 | + | | { kind: "token"; accessToken: string }; | |
| 28 | + | ||
| 29 | + | const SERVER_KEY = "g1t.server"; | |
| 30 | + | const CREDENTIALS_KEY = "g1t.credentials"; | |
| 31 | + | ||
| 32 | + | async function readJson<T>(key: string): Promise<T | null> { | |
| 33 | + | try { | |
| 34 | + | const text = await SecureStore.getItemAsync(key); | |
| 35 | + | return text ? (JSON.parse(text) as T) : null; | |
| 36 | + | } catch { | |
| 37 | + | // Unreadable (a restore to another phone, a keystore reset): start over. | |
| 38 | + | return null; | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | export async function loadServer(): Promise<Server> { | |
| 43 | + | const saved = await readJson<Server>(SERVER_KEY); | |
| 44 | + | return saved?.site && saved?.api ? saved : HOSTED; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export async function saveServer(server: Server): Promise<void> { | |
| 48 | + | await SecureStore.setItemAsync(SERVER_KEY, JSON.stringify(server)); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | export async function loadCredentials(): Promise<Credentials | null> { | |
| 52 | + | return readJson<Credentials>(CREDENTIALS_KEY); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | export async function saveCredentials(credentials: Credentials): Promise<void> { | |
| 56 | + | await SecureStore.setItemAsync(CREDENTIALS_KEY, JSON.stringify(credentials)); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | export async function clearCredentials(): Promise<void> { | |
| 60 | + | await SecureStore.deleteItemAsync(CREDENTIALS_KEY); | |
| 61 | + | } |
| 1 | + | import { connectorPath, connectorsFor } from "@g1t/contracts/connectors"; | |
| 2 | + | import { CUSTOMIZE, type CustomizeSection, customizePath } from "@g1t/contracts/customize"; | |
| 3 | + | import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace"; | |
| 4 | + | import { FOUNDATIONAL_SKILLS } from "@g1t/contracts/skills"; | |
| 5 | + | ||
| 6 | + | /** | |
| 7 | + | * Customize on the phone: Skills, Connectors and Plugins | |
| 8 | + | * (packages/contracts/src/customize.ts). What is listed comes from the | |
| 9 | + | * catalogs every g1t ships with; a workspace's own library, connections | |
| 10 | + | * and installs are read on the site for now, and added here once the API | |
| 11 | + | * serves them. | |
| 12 | + | */ | |
| 13 | + | ||
| 14 | + | export type CustomizeItem = { | |
| 15 | + | id: string; | |
| 16 | + | name: string; | |
| 17 | + | description: string; | |
| 18 | + | /** Available, Soon, or what else a listing says about itself. */ | |
| 19 | + | status: "available" | "soon" | "built-in"; | |
| 20 | + | /** A group to list it under. */ | |
| 21 | + | group: string; | |
| 22 | + | /** The site page that sets it up, for this workspace. */ | |
| 23 | + | sitePath: string; | |
| 24 | + | }; | |
| 25 | + | ||
| 26 | + | export function customizeItems(section: CustomizeSection, workspace: string): CustomizeItem[] { | |
| 27 | + | const fallback = customizePath(section, workspace); | |
| 28 | + | if (section === "skills") { | |
| 29 | + | return FOUNDATIONAL_SKILLS.map((skill) => ({ | |
| 30 | + | id: skill.id, | |
| 31 | + | name: skill.name, | |
| 32 | + | description: skill.description, | |
| 33 | + | status: "built-in" as const, | |
| 34 | + | group: "In every agent", | |
| 35 | + | sitePath: fallback, | |
| 36 | + | })); | |
| 37 | + | } | |
| 38 | + | if (section === "connectors") { | |
| 39 | + | return connectorsFor("workspace").map((connector) => ({ | |
| 40 | + | id: connector.id, | |
| 41 | + | name: connector.name, | |
| 42 | + | description: connector.description, | |
| 43 | + | status: connector.status, | |
| 44 | + | group: connector.status === "available" ? "Connect now" : "Coming", | |
| 45 | + | sitePath: connector.href ? connectorPath(connector.href, workspace) : `${fallback}/${encodeURIComponent(connector.id)}`, | |
| 46 | + | })); | |
| 47 | + | } | |
| 48 | + | return FIRST_PARTY_EXTENSIONS.map((extension) => ({ | |
| 49 | + | id: extension.id, | |
| 50 | + | name: extension.name, | |
| 51 | + | description: extension.tagline, | |
| 52 | + | status: extension.status, | |
| 53 | + | group: `${extension.publisher.name} · ${extension.category}`, | |
| 54 | + | sitePath: `${fallback}/${encodeURIComponent(extension.id)}`, | |
| 55 | + | })); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | export { CUSTOMIZE, customizePath, type CustomizeSection }; |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { routeFor, sitePathOf } from "./links.ts"; | |
| 5 | + | ||
| 6 | + | test("a g1t:// link names a site path, as on the desktop", () => { | |
| 7 | + | assert.equal(sitePathOf("g1t://acme/-/chat/dm/chn_1"), "/acme/-/chat/dm/chn_1"); | |
| 8 | + | assert.equal(sitePathOf("g1t:///acme/rocket/pull/3?tab=files"), "/acme/rocket/pull/3?tab=files"); | |
| 9 | + | assert.equal(sitePathOf("https://g1t.sh/acme"), null); | |
| 10 | + | }); | |
| 11 | + | ||
| 12 | + | test("Chat's pages open the app's Chat", () => { | |
| 13 | + | assert.equal(routeFor("/acme/-/chat"), "/chat?workspace=acme"); | |
| 14 | + | assert.equal(routeFor("/acme/-/chat/general"), "/chat/general?workspace=acme"); | |
| 15 | + | assert.equal(routeFor("/acme/-/chat/dm/chn_1"), "/chat/chn_1?workspace=acme"); | |
| 16 | + | assert.equal(routeFor("/acme/-/chat/browse"), null); | |
| 17 | + | }); | |
| 18 | + | ||
| 19 | + | test("Code's pages open the app's Code", () => { | |
| 20 | + | assert.equal(routeFor("/acme/rocket"), "/code/acme/rocket"); | |
| 21 | + | assert.equal(routeFor("/acme/rocket/pull/14"), "/code/acme/rocket/pull/14"); | |
| 22 | + | assert.equal(routeFor("/acme/rocket/issues/12"), "/code/acme/rocket/issues/12"); | |
| 23 | + | assert.equal(routeFor("/acme/-/overview"), "/code?workspace=acme"); | |
| 24 | + | assert.equal(routeFor("/acme/rocket/pull/14/files"), null); | |
| 25 | + | assert.equal(routeFor("/acme/rocket/issues/new"), null); | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | test("the Marketplace, integrations and skills open Customize", () => { | |
| 29 | + | assert.equal(routeFor("/acme/-/marketplace/integrations"), "/customize?workspace=acme"); | |
| 30 | + | assert.equal(routeFor("/acme/-/agents/skills"), "/customize?workspace=acme"); | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | test("the app's sign-in comes back to its own screen, and the site's own pages stay on the site", () => { | |
| 34 | + | assert.equal(routeFor("/oauth/callback?code=c&state=s"), "/oauth/callback?code=c&state=s"); | |
| 35 | + | assert.equal(routeFor("/settings/tokens"), null); | |
| 36 | + | assert.equal(routeFor("/"), "/"); | |
| 37 | + | }); |
| 1 | + | /** | |
| 2 | + | * Links into the app. A `g1t://` link names a site path, as on the desktop | |
| 3 | + | * (apps/desktop/src-tauri/src/links.rs): `g1t://acme/-/chat/dm/chn_1` is | |
| 4 | + | * the page `/acme/-/chat/dm/chn_1`. The app has screens for some of the | |
| 5 | + | * site's pages; `routeFor` says which screen shows a site path, and the | |
| 6 | + | * rest open on the site. | |
| 7 | + | * | |
| 8 | + | * Kept free of React Native imports so `npm test` runs it under Node. | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | /** Where the app's OAuth sign-in comes back to. */ | |
| 12 | + | export const OAUTH_CALLBACK_PATH = "/oauth/callback"; | |
| 13 | + | ||
| 14 | + | /** Path segments a workspace or account can never be named (the site's own pages). */ | |
| 15 | + | const NOT_OWNERS = new Set(["settings", "notifications", "oauth", "device", "login", "register", "-"]); | |
| 16 | + | ||
| 17 | + | /** | |
| 18 | + | * The app's screen for a site path, or null when the app has none and the | |
| 19 | + | * page should open on the site. | |
| 20 | + | */ | |
| 21 | + | export function routeFor(sitePath: string): string | null { | |
| 22 | + | const [pathname, query = ""] = sitePath.split("?", 2); | |
| 23 | + | const parts = pathname.split("/").filter(Boolean).map(decodeURIComponentSafe); | |
| 24 | + | const keep = query ? `?${query}` : ""; | |
| 25 | + | if (parts.length === 0) return "/"; | |
| 26 | + | if (`/${parts.join("/")}` === OAUTH_CALLBACK_PATH) return OAUTH_CALLBACK_PATH + keep; | |
| 27 | + | const [owner, second, third, fourth, fifth] = parts; | |
| 28 | + | if (NOT_OWNERS.has(owner.toLowerCase())) return null; | |
| 29 | + | const workspace = `workspace=${encodeURIComponent(owner)}`; | |
| 30 | + | ||
| 31 | + | // A workspace's own pages: /<workspace>/-/<page>. | |
| 32 | + | if (second === "-") { | |
| 33 | + | if (third === "chat") { | |
| 34 | + | if (!fourth) return `/chat?${workspace}`; | |
| 35 | + | if (fourth === "dm" && fifth) return `/chat/${encodeURIComponent(fifth)}?${workspace}`; | |
| 36 | + | if (fourth !== "browse" && fourth !== "dm" && !fifth) return `/chat/${encodeURIComponent(fourth)}?${workspace}`; | |
| 37 | + | return null; | |
| 38 | + | } | |
| 39 | + | if (third === "overview" && !fourth) return `/code?${workspace}`; | |
| 40 | + | if (third === "marketplace" || third === "integrations" || (third === "agents" && fourth === "skills")) { | |
| 41 | + | return `/customize?${workspace}`; | |
| 42 | + | } | |
| 43 | + | return null; | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // A repository's pages: /<owner>/<repo>[/pull/<n> | /issues/<n>]. | |
| 47 | + | if (!second) return null; | |
| 48 | + | const repo = `/code/${encodeURIComponent(owner)}/${encodeURIComponent(second)}`; | |
| 49 | + | if (!third) return repo; | |
| 50 | + | if (third === "pull" && fourth && /^\d+$/.test(fourth) && !fifth) return `${repo}/pull/${fourth}`; | |
| 51 | + | if (third === "issues" && fourth && /^\d+$/.test(fourth) && !fifth) return `${repo}/issues/${fourth}`; | |
| 52 | + | return null; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /** | |
| 56 | + | * The site path a `g1t://` link names, or null for any other link. | |
| 57 | + | * `g1t://acme/-/chat?x` is `/acme/-/chat?x`. | |
| 58 | + | */ | |
| 59 | + | export function sitePathOf(link: string): string | null { | |
| 60 | + | const match = /^g1t:\/\/([^?#]*)(\?[^#]*)?/i.exec(link); | |
| 61 | + | if (!match) return null; | |
| 62 | + | const path = `/${match[1].replace(/^\/+/, "")}`; | |
| 63 | + | return path + (match[2] ?? ""); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | function decodeURIComponentSafe(part: string): string { | |
| 67 | + | try { | |
| 68 | + | return decodeURIComponent(part); | |
| 69 | + | } catch { | |
| 70 | + | return part; | |
| 71 | + | } | |
| 72 | + | } |
| 1 | + | import { encodeOAuthClient } from "@g1t/contracts/oauth"; | |
| 2 | + | import * as AuthSession from "expo-auth-session"; | |
| 3 | + | import * as WebBrowser from "expo-web-browser"; | |
| 4 | + | import { Platform } from "react-native"; | |
| 5 | + | ||
| 6 | + | import type { Credentials } from "./credentials"; | |
| 7 | + | import { OAUTH_CALLBACK_PATH } from "./links"; | |
| 8 | + | import type { Server } from "./server"; | |
| 9 | + | ||
| 10 | + | /** | |
| 11 | + | * Signing in with g1t's OAuth 2.1, as any public client does | |
| 12 | + | * (docs.g1t.sh/guides/authentication/#signing-in-with-oauth): the | |
| 13 | + | * authorization code flow with PKCE (S256), no client secret, and a client | |
| 14 | + | * id that encodes the app's name and where it is sent back, so nothing is | |
| 15 | + | * registered first. | |
| 16 | + | * | |
| 17 | + | * The person approves on the site, in the system's browser, which keeps | |
| 18 | + | * their g1t session (and passkeys, password managers, two-factor) out of | |
| 19 | + | * the app. The site sends them back to `g1t://oauth/callback`. | |
| 20 | + | * | |
| 21 | + | * The app appears in Settings → Connected applications as "g1t for | |
| 22 | + | * Android" or "g1t for iOS", where its access can be narrowed or ended. | |
| 23 | + | */ | |
| 24 | + | ||
| 25 | + | // Lets the browser hand its result back when the app was in the background. | |
| 26 | + | WebBrowser.maybeCompleteAuthSession(); | |
| 27 | + | ||
| 28 | + | /** | |
| 29 | + | * What the app asks for: what Chat, Code and Customize show and do. The | |
| 30 | + | * person can untick any of these on the approval page. Chat has no scope: | |
| 31 | + | * see the README's "Chat and OAuth tokens". | |
| 32 | + | */ | |
| 33 | + | export const SCOPES = [ | |
| 34 | + | "account:read", | |
| 35 | + | "workspace:read", | |
| 36 | + | "repo:read", | |
| 37 | + | "code:read", | |
| 38 | + | "issues:read", | |
| 39 | + | "issues:write", | |
| 40 | + | "pull_requests:read", | |
| 41 | + | "pull_requests:write", | |
| 42 | + | "checks:read", | |
| 43 | + | "workflows:read", | |
| 44 | + | "notifications:read", | |
| 45 | + | "notifications:write", | |
| 46 | + | "agents:run", | |
| 47 | + | "memory:read", | |
| 48 | + | "artifacts:read", | |
| 49 | + | ]; | |
| 50 | + | ||
| 51 | + | /** The name shown on the approval page and in Connected applications. */ | |
| 52 | + | const CLIENT_NAME = Platform.OS === "ios" ? "g1t for iOS" : "g1t for Android"; | |
| 53 | + | ||
| 54 | + | /** | |
| 55 | + | * Where the site sends the person back: `g1t://oauth/callback` in a build | |
| 56 | + | * of the app, and Expo Go's own address while developing in it. | |
| 57 | + | */ | |
| 58 | + | export function redirectUri(): string { | |
| 59 | + | return AuthSession.makeRedirectUri({ scheme: "g1t", path: OAUTH_CALLBACK_PATH.slice(1) }); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | export function clientId(redirect: string): string { | |
| 63 | + | const id = encodeOAuthClient({ name: CLIENT_NAME, redirectUris: [redirect] }); | |
| 64 | + | if (!id) throw new Error(`g1t does not accept ${redirect} as a place to send you back.`); | |
| 65 | + | return id; | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | type Metadata = { | |
| 69 | + | issuer: string; | |
| 70 | + | authorization_endpoint: string; | |
| 71 | + | token_endpoint: string; | |
| 72 | + | revocation_endpoint?: string; | |
| 73 | + | }; | |
| 74 | + | ||
| 75 | + | /** The server's OAuth metadata (RFC 8414), from its API. */ | |
| 76 | + | async function metadata(server: Server): Promise<Metadata> { | |
| 77 | + | const response = await fetch(`${server.api}/.well-known/oauth-authorization-server`, { | |
| 78 | + | headers: { accept: "application/json" }, | |
| 79 | + | }); | |
| 80 | + | if (!response.ok) throw new Error(`${server.api} did not say how to sign in (${response.status}).`); | |
| 81 | + | return (await response.json()) as Metadata; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | function discovery(meta: Metadata): AuthSession.DiscoveryDocument { | |
| 85 | + | return { | |
| 86 | + | authorizationEndpoint: meta.authorization_endpoint, | |
| 87 | + | tokenEndpoint: meta.token_endpoint, | |
| 88 | + | revocationEndpoint: meta.revocation_endpoint, | |
| 89 | + | }; | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | export class SignInCancelled extends Error { | |
| 93 | + | constructor() { | |
| 94 | + | super("Sign-in was cancelled."); | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /** | |
| 99 | + | * Opens the site's approval page and, once the person approves, trades the | |
| 100 | + | * code for tokens. Throws {@link SignInCancelled} when they close the page | |
| 101 | + | * or deny. | |
| 102 | + | */ | |
| 103 | + | export async function signInWithOAuth(server: Server): Promise<Credentials> { | |
| 104 | + | const meta = await metadata(server); | |
| 105 | + | const redirect = redirectUri(); | |
| 106 | + | const id = clientId(redirect); | |
| 107 | + | const request = new AuthSession.AuthRequest({ | |
| 108 | + | clientId: id, | |
| 109 | + | redirectUri: redirect, | |
| 110 | + | scopes: SCOPES, | |
| 111 | + | usePKCE: true, | |
| 112 | + | codeChallengeMethod: AuthSession.CodeChallengeMethod.S256, | |
| 113 | + | }); | |
| 114 | + | const result = await request.promptAsync(discovery(meta)); | |
| 115 | + | if (result.type === "cancel" || result.type === "dismiss") throw new SignInCancelled(); | |
| 116 | + | if (result.type !== "success") { | |
| 117 | + | const denied = result.type === "error" && result.params.error === "access_denied"; | |
| 118 | + | if (denied) throw new SignInCancelled(); | |
| 119 | + | throw new Error(result.type === "error" ? (result.params.error_description ?? result.params.error ?? "Sign-in failed.") : "Sign-in failed."); | |
| 120 | + | } | |
| 121 | + | // RFC 9207: the code must come from the server the app asked. | |
| 122 | + | if (result.params.iss && result.params.iss.replace(/\/$/, "") !== meta.issuer.replace(/\/$/, "")) { | |
| 123 | + | throw new Error("The sign-in came back from a different server than the one asked. Try again."); | |
| 124 | + | } | |
| 125 | + | const tokens = await AuthSession.exchangeCodeAsync( | |
| 126 | + | { | |
| 127 | + | clientId: id, | |
| 128 | + | code: result.params.code, | |
| 129 | + | redirectUri: redirect, | |
| 130 | + | extraParams: { code_verifier: request.codeVerifier ?? "" }, | |
| 131 | + | }, | |
| 132 | + | discovery(meta), | |
| 133 | + | ); | |
| 134 | + | return fromTokenResponse(tokens); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | function fromTokenResponse(tokens: AuthSession.TokenResponse): Credentials { | |
| 138 | + | if (!tokens.refreshToken) throw new Error("g1t did not return a refresh token."); | |
| 139 | + | return { | |
| 140 | + | kind: "oauth", | |
| 141 | + | accessToken: tokens.accessToken, | |
| 142 | + | refreshToken: tokens.refreshToken, | |
| 143 | + | expiresAt: (tokens.issuedAt + (tokens.expiresIn ?? 30 * 24 * 3600)) * 1000, | |
| 144 | + | scope: tokens.scope ?? "", | |
| 145 | + | }; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /** Raised when the refresh token no longer works: the person must sign in again. */ | |
| 149 | + | export class SignedOut extends Error { | |
| 150 | + | constructor(message = "You were signed out. Sign in again.") { | |
| 151 | + | super(message); | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /** | |
| 156 | + | * The next pair of tokens. A refresh token works once: the old access | |
| 157 | + | * token stops working, so callers share one refresh at a time | |
| 158 | + | * (lib/client.ts). | |
| 159 | + | */ | |
| 160 | + | export async function refresh(server: Server, credentials: Extract<Credentials, { kind: "oauth" }>): Promise<Credentials> { | |
| 161 | + | const meta = await metadata(server); | |
| 162 | + | try { | |
| 163 | + | const tokens = await AuthSession.refreshAsync( | |
| 164 | + | { clientId: clientId(redirectUri()), refreshToken: credentials.refreshToken }, | |
| 165 | + | discovery(meta), | |
| 166 | + | ); | |
| 167 | + | return fromTokenResponse(tokens); | |
| 168 | + | } catch (error) { | |
| 169 | + | // invalid_grant: used, expired or revoked in Connected applications. | |
| 170 | + | if (error instanceof AuthSession.TokenError) throw new SignedOut(); | |
| 171 | + | throw error; | |
| 172 | + | } | |
| 173 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { HOSTED, apiFor, parseOrigin, parseServer, socketUrl } from "./server.ts"; | |
| 5 | + | ||
| 6 | + | test("an origin is all that is kept", () => { | |
| 7 | + | assert.equal(parseOrigin("https://g1t.sh/acme/-/chat?x#y"), "https://g1t.sh"); | |
| 8 | + | assert.equal(parseOrigin(" g1t.example.com "), "https://g1t.example.com"); | |
| 9 | + | assert.equal(parseOrigin("http://192.168.1.20:5173/login"), "http://192.168.1.20:5173"); | |
| 10 | + | assert.equal(parseOrigin("ftp://g1t.sh"), null); | |
| 11 | + | assert.equal(parseOrigin("not a url at all"), null); | |
| 12 | + | assert.equal(parseOrigin(""), null); | |
| 13 | + | }); | |
| 14 | + | ||
| 15 | + | test("hosted g1t's API is api.g1t.sh, and a self-hosted one's is api. on its host", () => { | |
| 16 | + | assert.equal(apiFor("https://g1t.sh"), "https://api.g1t.sh"); | |
| 17 | + | assert.equal(apiFor("https://g1t.example.com"), "https://api.g1t.example.com"); | |
| 18 | + | assert.deepEqual(parseServer("g1t.sh"), HOSTED); | |
| 19 | + | }); | |
| 20 | + | ||
| 21 | + | test("an API given by hand wins", () => { | |
| 22 | + | assert.deepEqual(parseServer("http://localhost:5173", "http://localhost:8789/"), { | |
| 23 | + | site: "http://localhost:5173", | |
| 24 | + | api: "http://localhost:8789", | |
| 25 | + | }); | |
| 26 | + | assert.equal(parseServer("g1t.sh", "javascript:alert(1)"), null); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("sockets follow the site's scheme", () => { | |
| 30 | + | assert.equal(socketUrl(HOSTED, "/acme/-/chat/live?channel=chn_1"), "wss://g1t.sh/acme/-/chat/live?channel=chn_1"); | |
| 31 | + | assert.equal(socketUrl({ site: "http://localhost:5173", api: "" }, "/-/live"), "ws://localhost:5173/-/live"); | |
| 32 | + | }); |
| 1 | + | /** | |
| 2 | + | * Which g1t the app talks to. Hosted g1t is the site `https://g1t.sh` and | |
| 3 | + | * the API `https://api.g1t.sh`; a self-hosted g1t sets its own (SITE_URL | |
| 4 | + | * and API_URL, apps/api/src/addresses.rs), so the app keeps both. | |
| 5 | + | * | |
| 6 | + | * The site is where people sign in and approve the app, and where Chat's | |
| 7 | + | * routes are; the API is the REST API and the OAuth issuer. | |
| 8 | + | * | |
| 9 | + | * Kept free of React Native imports so `npm test` runs it under Node. | |
| 10 | + | */ | |
| 11 | + | ||
| 12 | + | export type Server = { | |
| 13 | + | /** An origin, no trailing slash: `https://g1t.sh`. */ | |
| 14 | + | site: string; | |
| 15 | + | /** An origin, no trailing slash: `https://api.g1t.sh`. */ | |
| 16 | + | api: string; | |
| 17 | + | }; | |
| 18 | + | ||
| 19 | + | export const HOSTED: Server = { site: "https://g1t.sh", api: "https://api.g1t.sh" }; | |
| 20 | + | ||
| 21 | + | /** | |
| 22 | + | * An origin as typed: http or https with a host, `https://` when no scheme | |
| 23 | + | * is given; only the origin is kept. Null when it is not a web address. | |
| 24 | + | * Plain http is accepted for a g1t on this network, as in development. | |
| 25 | + | */ | |
| 26 | + | export function parseOrigin(text: string): string | null { | |
| 27 | + | const trimmed = text.trim(); | |
| 28 | + | if (!trimmed) return null; | |
| 29 | + | const withScheme = trimmed.includes("://") ? trimmed : `https://${trimmed}`; | |
| 30 | + | let url: URL; | |
| 31 | + | try { | |
| 32 | + | url = new URL(withScheme); | |
| 33 | + | } catch { | |
| 34 | + | return null; | |
| 35 | + | } | |
| 36 | + | if (url.protocol !== "https:" && url.protocol !== "http:") return null; | |
| 37 | + | if (!url.hostname) return null; | |
| 38 | + | return `${url.protocol}//${url.host}`; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /** | |
| 42 | + | * The API's address for a site, when only the site was given: hosted | |
| 43 | + | * g1t's own, or `api.` in front of a self-hosted site's host, which is | |
| 44 | + | * how deploy/self-host names it. | |
| 45 | + | */ | |
| 46 | + | export function apiFor(site: string): string { | |
| 47 | + | if (site === HOSTED.site) return HOSTED.api; | |
| 48 | + | const url = new URL(site); | |
| 49 | + | return `${url.protocol}//api.${url.host}`; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /** A server from what was typed: the site, and the API if it was given. */ | |
| 53 | + | export function parseServer(siteText: string, apiText?: string): Server | null { | |
| 54 | + | const site = parseOrigin(siteText); | |
| 55 | + | if (!site) return null; | |
| 56 | + | if (apiText && apiText.trim()) { | |
| 57 | + | const api = parseOrigin(apiText); | |
| 58 | + | if (!api) return null; | |
| 59 | + | return { site, api }; | |
| 60 | + | } | |
| 61 | + | return { site, api: apiFor(site) }; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | export function isHosted(server: Server): boolean { | |
| 65 | + | return server.site === HOSTED.site && server.api === HOSTED.api; | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /** The site's address for a path, such as a page to open in the browser. */ | |
| 69 | + | export function siteUrl(server: Server, path: string): string { | |
| 70 | + | return server.site + (path.startsWith("/") ? path : `/${path}`); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /** The socket address for one of the site's live paths. */ | |
| 74 | + | export function socketUrl(server: Server, path: string): string { | |
| 75 | + | return siteUrl(server, path).replace(/^http/, "ws"); | |
| 76 | + | } |
| 1 | + | import type { Membership, User } from "@g1t/contracts"; | |
| 2 | + | import { useQueryClient } from "@tanstack/react-query"; | |
| 3 | + | import * as SecureStore from "expo-secure-store"; | |
| 4 | + | import { type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; | |
| 5 | + | ||
| 6 | + | import { ApiError, Client } from "./client"; | |
| 7 | + | import { type Credentials, clearCredentials, loadCredentials, loadServer, saveCredentials, saveServer } from "./credentials"; | |
| 8 | + | import { signInWithOAuth } from "./oauth"; | |
| 9 | + | import type { Server } from "./server"; | |
| 10 | + | ||
| 11 | + | /** | |
| 12 | + | * Who is signed in, to which g1t, and in which workspace. Everything that | |
| 13 | + | * talks to g1t reads its {@link Client} from here. | |
| 14 | + | */ | |
| 15 | + | ||
| 16 | + | /** `GET /user`: the person, their workspaces, and what the token may do. */ | |
| 17 | + | export type Me = User & { token?: { token_id: string; scopes?: string[] } }; | |
| 18 | + | ||
| 19 | + | type State = | |
| 20 | + | | { status: "loading"; server: Server | null } | |
| 21 | + | | { status: "signed-out"; server: Server } | |
| 22 | + | | { status: "signed-in"; server: Server; client: Client; me: Me; workspace: Membership | null }; | |
| 23 | + | ||
| 24 | + | type SignedInState = Extract<State, { status: "signed-in" }>; | |
| 25 | + | ||
| 26 | + | type SessionApi = { | |
| 27 | + | state: State; | |
| 28 | + | /** | |
| 29 | + | * The last signed-in state, kept through signing out: the signed-in | |
| 30 | + | * screens render once more as they leave, and read it then. | |
| 31 | + | */ | |
| 32 | + | lastSignedIn: SignedInState | null; | |
| 33 | + | /** Opens the site to approve the app, then signs in. */ | |
| 34 | + | signIn: () => Promise<void>; | |
| 35 | + | /** Signs in with a personal access token (`g1t_…`). */ | |
| 36 | + | signInWithToken: (token: string) => Promise<void>; | |
| 37 | + | signOut: () => Promise<void>; | |
| 38 | + | /** Points the app at another g1t; signs out of the one before. */ | |
| 39 | + | changeServer: (server: Server) => Promise<void>; | |
| 40 | + | /** Switches workspace by slug. */ | |
| 41 | + | selectWorkspace: (slug: string) => Promise<void>; | |
| 42 | + | }; | |
| 43 | + | ||
| 44 | + | const WORKSPACE_KEY = "g1t.workspace"; | |
| 45 | + | ||
| 46 | + | const SessionContext = createContext<SessionApi | null>(null); | |
| 47 | + | ||
| 48 | + | export function SessionProvider({ children }: { children: ReactNode }) { | |
| 49 | + | const [state, setState] = useState<State>({ status: "loading", server: null }); | |
| 50 | + | const queries = useQueryClient(); | |
| 51 | + | const serverRef = useRef<Server | null>(null); | |
| 52 | + | const lastSignedIn = useRef<SignedInState | null>(null); | |
| 53 | + | if (state.status === "signed-in") lastSignedIn.current = state; | |
| 54 | + | ||
| 55 | + | const signedOut = useCallback( | |
| 56 | + | async (server: Server) => { | |
| 57 | + | await clearCredentials(); | |
| 58 | + | queries.clear(); | |
| 59 | + | setState({ status: "signed-out", server }); | |
| 60 | + | }, | |
| 61 | + | [queries], | |
| 62 | + | ); | |
| 63 | + | ||
| 64 | + | /** Makes a client for credentials, reads who they are, and signs in. */ | |
| 65 | + | const start = useCallback( | |
| 66 | + | async (server: Server, credentials: Credentials) => { | |
| 67 | + | const client = new Client({ | |
| 68 | + | server, | |
| 69 | + | credentials, | |
| 70 | + | onCredentials: (next) => { | |
| 71 | + | if (next === null) void signedOut(server); | |
| 72 | + | }, | |
| 73 | + | }); | |
| 74 | + | const me = await client.api<Me>("/user"); | |
| 75 | + | if ((me.kind ?? "user") !== "user") { | |
| 76 | + | throw new ApiError("forbidden", "Sign in as yourself: a workspace's or an agent's token cannot use the app."); | |
| 77 | + | } | |
| 78 | + | const saved = await SecureStore.getItemAsync(WORKSPACE_KEY).catch(() => null); | |
| 79 | + | const workspaces = me.workspaces ?? []; | |
| 80 | + | const workspace = workspaces.find((one) => one.slug === saved) ?? workspaces[0] ?? null; | |
| 81 | + | setState({ status: "signed-in", server, client, me, workspace }); | |
| 82 | + | }, | |
| 83 | + | [signedOut], | |
| 84 | + | ); | |
| 85 | + | ||
| 86 | + | useEffect(() => { | |
| 87 | + | void (async () => { | |
| 88 | + | const server = await loadServer(); | |
| 89 | + | serverRef.current = server; | |
| 90 | + | const credentials = await loadCredentials(); | |
| 91 | + | if (!credentials) return setState({ status: "signed-out", server }); | |
| 92 | + | try { | |
| 93 | + | await start(server, credentials); | |
| 94 | + | } catch (error) { | |
| 95 | + | // Offline at launch keeps the credentials for next time; anything | |
| 96 | + | // else (revoked, expired) signs out. | |
| 97 | + | if (error instanceof ApiError && error.code === "offline") { | |
| 98 | + | setState({ status: "signed-out", server }); | |
| 99 | + | return; | |
| 100 | + | } | |
| 101 | + | await signedOut(server); | |
| 102 | + | } | |
| 103 | + | })(); | |
| 104 | + | }, [start, signedOut]); | |
| 105 | + | ||
| 106 | + | const api = useMemo<SessionApi>(() => { | |
| 107 | + | const current = () => serverRef.current ?? state.server; | |
| 108 | + | return { | |
| 109 | + | state, | |
| 110 | + | lastSignedIn: lastSignedIn.current, | |
| 111 | + | signIn: async () => { | |
| 112 | + | const server = current(); | |
| 113 | + | if (!server) return; | |
| 114 | + | const credentials = await signInWithOAuth(server); | |
| 115 | + | await saveCredentials(credentials); | |
| 116 | + | await start(server, credentials); | |
| 117 | + | }, | |
| 118 | + | signInWithToken: async (token) => { | |
| 119 | + | const server = current(); | |
| 120 | + | if (!server) return; | |
| 121 | + | const trimmed = token.trim(); | |
| 122 | + | if (!trimmed.startsWith("g1t_")) throw new ApiError("failed", "An access token starts with g1t_."); | |
| 123 | + | const credentials: Credentials = { kind: "token", accessToken: trimmed }; | |
| 124 | + | await start(server, credentials); | |
| 125 | + | await saveCredentials(credentials); | |
| 126 | + | }, | |
| 127 | + | signOut: async () => { | |
| 128 | + | const server = current(); | |
| 129 | + | if (server) await signedOut(server); | |
| 130 | + | }, | |
| 131 | + | changeServer: async (server) => { | |
| 132 | + | await saveServer(server); | |
| 133 | + | serverRef.current = server; | |
| 134 | + | await signedOut(server); | |
| 135 | + | }, | |
| 136 | + | selectWorkspace: async (slug) => { | |
| 137 | + | if (state.status !== "signed-in") return; | |
| 138 | + | const workspace = state.me.workspaces?.find((one) => one.slug.toLowerCase() === slug.toLowerCase()); | |
| 139 | + | if (!workspace || workspace.slug === state.workspace?.slug) return; | |
| 140 | + | await SecureStore.setItemAsync(WORKSPACE_KEY, workspace.slug); | |
| 141 | + | setState({ ...state, workspace }); | |
| 142 | + | }, | |
| 143 | + | }; | |
| 144 | + | }, [state, start, signedOut]); | |
| 145 | + | ||
| 146 | + | return <SessionContext.Provider value={api}>{children}</SessionContext.Provider>; | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | export function useSession(): SessionApi { | |
| 150 | + | const session = useContext(SessionContext); | |
| 151 | + | if (!session) throw new Error("useSession is used outside SessionProvider."); | |
| 152 | + | return session; | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /** The signed-in session, for screens that only show when signed in. */ | |
| 156 | + | export function useSignedIn() { | |
| 157 | + | const { state, lastSignedIn, ...rest } = useSession(); | |
| 158 | + | const signedIn = state.status === "signed-in" ? state : lastSignedIn; | |
| 159 | + | if (!signedIn) throw new Error("This screen needs a signed-in session."); | |
| 160 | + | return { ...signedIn, ...rest }; | |
| 161 | + | } |
| 1 | + | /** "now", "5m", "3h", "2d", or a date, for lists. */ | |
| 2 | + | export function ago(iso: string, now = Date.now()): string { | |
| 3 | + | const then = Date.parse(iso); | |
| 4 | + | if (Number.isNaN(then)) return ""; | |
| 5 | + | const seconds = Math.max(0, Math.round((now - then) / 1000)); | |
| 6 | + | if (seconds < 60) return "now"; | |
| 7 | + | const minutes = Math.round(seconds / 60); | |
| 8 | + | if (minutes < 60) return `${minutes}m`; | |
| 9 | + | const hours = Math.round(minutes / 60); | |
| 10 | + | if (hours < 24) return `${hours}h`; | |
| 11 | + | const days = Math.round(hours / 24); | |
| 12 | + | if (days < 7) return `${days}d`; | |
| 13 | + | return new Date(then).toLocaleDateString(undefined, { month: "short", day: "numeric" }); | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /** A message's time of day: "14:05". */ | |
| 17 | + | export function clock(iso: string): string { | |
| 18 | + | const date = new Date(iso); | |
| 19 | + | return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); | |
| 20 | + | } |
| 1 | + | import { useColorScheme } from "react-native"; | |
| 2 | + | ||
| 3 | + | import { dark, light, type Palette } from "./tokens"; | |
| 4 | + | ||
| 5 | + | export { fonts, radius, space, type Palette } from "./tokens"; | |
| 6 | + | ||
| 7 | + | /** The palette for the phone's light or dark setting; dark when it has none. */ | |
| 8 | + | export function useColors(): Palette { | |
| 9 | + | return useColorScheme() === "light" ? light : dark; | |
| 10 | + | } |
| 1 | + | /** | |
| 2 | + | * g1t's colours, as packages/theme/tokens.css names them, for React | |
| 3 | + | * Native, which cannot read CSS variables. Keep the two in step: a colour | |
| 4 | + | * changed there changes here. | |
| 5 | + | * | |
| 6 | + | * Dark is g1t's default, as on the site; light follows the phone's setting. | |
| 7 | + | */ | |
| 8 | + | ||
| 9 | + | export type Palette = { | |
| 10 | + | bg: string; | |
| 11 | + | surface: string; | |
| 12 | + | raised: string; | |
| 13 | + | line: string; | |
| 14 | + | lineStrong: string; | |
| 15 | + | fg: string; | |
| 16 | + | fgSoft: string; | |
| 17 | + | muted: string; | |
| 18 | + | faint: string; | |
| 19 | + | accent: string; | |
| 20 | + | accentDim: string; | |
| 21 | + | accentLow: string; | |
| 22 | + | /** Text on an accent-filled button. */ | |
| 23 | + | onAccent: string; | |
| 24 | + | success: string; | |
| 25 | + | successLow: string; | |
| 26 | + | info: string; | |
| 27 | + | merged: string; | |
| 28 | + | warn: string; | |
| 29 | + | danger: string; | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | export const dark: Palette = { | |
| 33 | + | bg: "#0f0f11", | |
| 34 | + | surface: "#161618", | |
| 35 | + | raised: "#1e1e21", | |
| 36 | + | line: "#28282c", | |
| 37 | + | lineStrong: "#38383e", | |
| 38 | + | fg: "#ededef", | |
| 39 | + | fgSoft: "#dcdce0", | |
| 40 | + | muted: "#a0a0a8", | |
| 41 | + | faint: "#86868e", | |
| 42 | + | accent: "#b6a8ff", | |
| 43 | + | accentDim: "#8577e0", | |
| 44 | + | accentLow: "#221e38", | |
| 45 | + | onAccent: "#0f0f11", | |
| 46 | + | success: "#86efc4", | |
| 47 | + | successLow: "#10302a", | |
| 48 | + | info: "#8ab4ff", | |
| 49 | + | merged: "#a678f5", | |
| 50 | + | warn: "#ffbd8c", | |
| 51 | + | danger: "#ff8394", | |
| 52 | + | }; | |
| 53 | + | ||
| 54 | + | export const light: Palette = { | |
| 55 | + | bg: "#fbfbfa", | |
| 56 | + | surface: "#ffffff", | |
| 57 | + | raised: "#f1f1ef", | |
| 58 | + | line: "#e4e4e1", | |
| 59 | + | lineStrong: "#d2d2ce", | |
| 60 | + | fg: "#18181b", | |
| 61 | + | fgSoft: "#2e2e33", | |
| 62 | + | muted: "#5c5c66", | |
| 63 | + | faint: "#686871", | |
| 64 | + | accent: "#6b52c8", | |
| 65 | + | accentDim: "#9584dc", | |
| 66 | + | accentLow: "#ece8fb", | |
| 67 | + | onAccent: "#ffffff", | |
| 68 | + | success: "#0d7a52", | |
| 69 | + | successLow: "#e2f4ec", | |
| 70 | + | info: "#2160cc", | |
| 71 | + | merged: "#7c3fd1", | |
| 72 | + | warn: "#a84f00", | |
| 73 | + | danger: "#c42a43", | |
| 74 | + | }; | |
| 75 | + | ||
| 76 | + | export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 } as const; | |
| 77 | + | ||
| 78 | + | export const radius = { sm: 6, md: 10, lg: 14, full: 999 } as const; | |
| 79 | + | ||
| 80 | + | /** | |
| 81 | + | * The site's faces (Hanken Grotesk, Bricolage Grotesque, IBM Plex Mono) | |
| 82 | + | * ship as woff2, which phones cannot load; until TTF copies are bundled | |
| 83 | + | * with expo-font, the system's faces stand in. | |
| 84 | + | */ | |
| 85 | + | export const fonts = { mono: "monospace" } as const; |
| 1 | + | { | |
| 2 | + | "extends": "expo/tsconfig.base", | |
| 3 | + | "compilerOptions": { | |
| 4 | + | "strict": true, | |
| 5 | + | "skipLibCheck": true, | |
| 6 | + | "paths": { | |
| 7 | + | "@/*": ["./src/*"], | |
| 8 | + | "@/assets/*": ["./assets/*"] | |
| 9 | + | } | |
| 10 | + | }, | |
| 11 | + | "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"], | |
| 12 | + | // Node runs the tests on the files as they are (`npm test`). | |
| 13 | + | "exclude": ["node_modules", "src/**/*.test.ts"] | |
| 14 | + | } |
Binary or large file; its contents are not shown.
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "license": "MIT", | |
| 7 | − | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts" }, | |
| 7 | + | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts", "./oauth": "./src/oauth.ts", "./customize": "./src/customize.ts", "./skills": "./src/skills.ts" }, | |
| 8 | 8 | "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" } | |
| 9 | 9 | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { CUSTOMIZE, CUSTOMIZE_SECTIONS, customizePath, sectionOfListing } from "./customize.ts"; | |
| 5 | + | ||
| 6 | + | test("every Marketplace listing has a place in Customize", () => { | |
| 7 | + | assert.equal(sectionOfListing("integration"), "connectors"); | |
| 8 | + | assert.equal(sectionOfListing("extension"), "plugins"); | |
| 9 | + | for (const section of CUSTOMIZE_SECTIONS) assert.equal(CUSTOMIZE[section].id, section); | |
| 10 | + | }); | |
| 11 | + | ||
| 12 | + | test("a section's site path names its workspace", () => { | |
| 13 | + | assert.equal(customizePath("connectors", "acme"), "/acme/-/marketplace/integrations"); | |
| 14 | + | assert.equal(customizePath("skills", "a b"), "/a%20b/-/agents/skills"); | |
| 15 | + | }); |
| 1 | + | /** | |
| 2 | + | * Customize: one place for what shapes how a workspace's agents work, in | |
| 3 | + | * three parts. It is the Marketplace's successor, and what the phone app | |
| 4 | + | * shows today; the site moves to it in its own change. | |
| 5 | + | * | |
| 6 | + | * - **Skills**: what agents know how to do, in the open SKILL.md format | |
| 7 | + | * (./skill-library.ts): the foundational skills every agent has, and the | |
| 8 | + | * workspace's library. | |
| 9 | + | * - **Connectors**: what agents can reach: the tools a team already uses | |
| 10 | + | * (./connectors.ts). Today's Marketplace integrations. | |
| 11 | + | * - **Plugins**: what adds pages, cards, tools and agent roles to the | |
| 12 | + | * workspace (./marketplace.ts's `ExtensionManifest`). Today's | |
| 13 | + | * Marketplace extensions. | |
| 14 | + | * | |
| 15 | + | * A Marketplace listing (`integration:<id>`, `extension:<id>`) keeps its | |
| 16 | + | * reference; {@link sectionOfListing} says which part shows it. No imports | |
| 17 | + | * beyond types, so it runs anywhere: services, the site, the phone. | |
| 18 | + | */ | |
| 19 | + | ||
| 20 | + | import type { ListingKind } from "./marketplace"; | |
| 21 | + | ||
| 22 | + | export type CustomizeSection = "skills" | "connectors" | "plugins"; | |
| 23 | + | ||
| 24 | + | export const CUSTOMIZE_SECTIONS: readonly CustomizeSection[] = ["skills", "connectors", "plugins"]; | |
| 25 | + | ||
| 26 | + | export type CustomizeSectionInfo = { | |
| 27 | + | id: CustomizeSection; | |
| 28 | + | title: string; | |
| 29 | + | /** One line, for the section's row. */ | |
| 30 | + | description: string; | |
| 31 | + | /** Where the site manages it today, with `:workspace` to fill in. */ | |
| 32 | + | sitePath: string; | |
| 33 | + | }; | |
| 34 | + | ||
| 35 | + | export const CUSTOMIZE: Record<CustomizeSection, CustomizeSectionInfo> = { | |
| 36 | + | skills: { | |
| 37 | + | id: "skills", | |
| 38 | + | title: "Skills", | |
| 39 | + | description: "What agents know how to do: the skills every agent has, and your workspace's library.", | |
| 40 | + | sitePath: "/:workspace/-/agents/skills", | |
| 41 | + | }, | |
| 42 | + | connectors: { | |
| 43 | + | id: "connectors", | |
| 44 | + | title: "Connectors", | |
| 45 | + | description: "The tools your team already uses, connected so agents can work with their data.", | |
| 46 | + | sitePath: "/:workspace/-/marketplace/integrations", | |
| 47 | + | }, | |
| 48 | + | plugins: { | |
| 49 | + | id: "plugins", | |
| 50 | + | title: "Plugins", | |
| 51 | + | description: "Pages, cards, tools and agent roles for what your team does besides code.", | |
| 52 | + | sitePath: "/:workspace/-/marketplace/extensions", | |
| 53 | + | }, | |
| 54 | + | }; | |
| 55 | + | ||
| 56 | + | /** The part of Customize that shows a Marketplace listing of this kind. */ | |
| 57 | + | export function sectionOfListing(kind: ListingKind): CustomizeSection { | |
| 58 | + | return kind === "integration" ? "connectors" : "plugins"; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /** A section's site path with its workspace filled in. */ | |
| 62 | + | export function customizePath(section: CustomizeSection, workspace: string): string { | |
| 63 | + | return CUSTOMIZE[section].sitePath.replace(/:workspace\b/g, encodeURIComponent(workspace)); | |
| 64 | + | } |
| 15 | 15 | export * from "./compute"; | |
| 16 | 16 | export * from "./connectors"; | |
| 17 | 17 | export * from "./context"; | |
| 18 | + | export * from "./customize"; | |
| 18 | 19 | export * from "./d1"; | |
| 19 | 20 | export * from "./datasets"; | |
| 20 | 21 | export * from "./docs"; |