Files
llink/js/mobile/src/api/client.ts
T
3d80ac2993 feat: increase feature parity between mobile and desktop (#298)
* mobile: add invitations, network creation, and settings (parity phase 1)

Surface backend capabilities that already existed in the mobile API client
but had no UI:

- NetworkListScreen now lists pending invitations with an Accept action and
  a header "+" to create a network; empty state offers creation instead of
  pointing users to desktop.
- New CreateNetworkSheet and use-invitations hooks (accept invite, create
  network) following the existing react-query patterns.
- SettingsScreen replaces its placeholder with an email-notifications toggle
  (optimistic, mirrors desktop), app version, and sign out.
- Wire the previously-unreachable Settings row into the Drawer.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: network member management and avatars (parity phase 2)

- New NetworkSettingsScreen (reachable from the stream-list header) lists
  members with admin remove, an invite-by-email sheet, and pending
  invitations with revoke — backed by new use-member-management hooks.
- Avatars: add avatar_object_id to HumanSchema, uploadAvatar/deleteAvatar/
  getAvatarDownloadUrl client methods (raw PUT via expo-file-system), a
  use-avatar-url hook, and image rendering in the shared Avatar component.
  AccountScreen gains a tap-to-change profile picture via expo-image-picker.
- auth-store gains refreshUser to pick up avatar changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: task particles — view, edit, and compose (parity phase 3)

Bring the task particle to parity with desktop's richer model:

- Replace the thin `quest` schema with desktop's `task` model
  (ChecklistItem, TaskProperties: title/notes/checklist/assigned_to/done)
  in the discriminated union, the Firestore converter, and consumers
  (StreamCard, FallbackParticleView).
- New TaskParticleView renders an editable card (round done checkbox,
  title, notes, checklist with add/toggle/edit/remove, assignee chips)
  persisting each edit to Firestore; an 8s dwell auto-advances and
  field focus suspends playback. Wired into StreamView's render switch.
- Compose: a task button in the ComposeDock opens a TaskComposeSheet
  (createTaskParticle helper). Gated off in the new-stream flow, where a
  stream's first particle must be text or media.

Note: particles are written client-side to Firestore, matching desktop;
Orion's REST validator still only accepts `quest`, which is a pre-existing
inconsistency to reconcile backend-side separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: paper and file particle views (parity phase 4)

- Extract the shared markdown renderer/theme out of TextParticleView into
  a reusable MarkdownBody component (DRY).
- PaperParticleView renders desktop-authored documents (title + markdown)
  with a length-based dwell.
- FileParticleView shows name/size and a Download action that opens a
  signed URL via the OS.
- Both wired into StreamView's render switch; FallbackParticleView is now a
  true catch-all for unknown/folder types only.

Deferred (documented for a follow-up phase): composing papers/files from
mobile, particle attachments + lightbox, and link previews in text.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: billing & usage in network settings (parity phase 5)

Surface the network plan, daily usage, and Stripe management — all backed
by client methods that already existed. New use-billing hooks and a
BillingSection (mirroring desktop): every member sees the plan + usage
summary; admins get cadence selection + "Upgrade to Pro" (checkout) and
"Manage subscription" (portal), opening Stripe in the system browser.
Added to NetworkSettingsScreen.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: let users dismiss the keyboard from a task card

Focusing a task field opened the keyboard with no way out — it covered the
card and the stream's tap-to-advance zones. Now:

- A "Done" pill appears at the card's top-right while editing (reusing the
  existing `editing` flag) and calls Keyboard.dismiss(); the title row
  reserves space so the pill never overlaps a long title.
- The card ScrollView gains keyboardDismissMode (interactive on iOS, on-drag
  on Android) so dragging the card also dismisses the keyboard.

Dismissing blurs the active field, which flips `editing` off and resumes the
dwell timer and tap navigation automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* decrease clutter in stream-view

* format

* format

* consolidate avatar

---------

Co-authored-by: Claude <[email protected]>
2026-06-21 10:38:16 -07:00

314 lines
7.8 KiB
TypeScript

import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy';
import { appConfig } from '@/config/env';
import { ApiError } from '@/lib/errors';
import type { z } from 'zod';
import {
BillingStatusSchema,
CheckoutSessionResponseSchema,
DepotObjectSchema,
FirebaseTokenResponseSchema,
GetLivekitTokenResponseSchema,
HumanSchema,
ListInvitationsResponseSchema,
ListNetworksResponseSchema,
NetworkSchema,
NetworkUsageSchema,
PortalSessionResponseSchema,
PrepareUploadResponseSchema,
SignInResponseSchema,
} from './types';
import type {
AcceptInvitationRequest,
AddMembersRequest,
BillingCadence,
CreateNetworkRequest,
PrepareUploadRequest,
RequestCodeRequest,
RevokeInvitationRequest,
SignInRequest,
} from './types';
/**
* HTTP transport for Orion. Holds the bearer token as private state — the auth
* store pushes it in via {@link setToken} on sign-in / restore and clears it
* on sign-out. The client itself has no opinion about what a 401 means; it
* just throws, and the query-client onError handler is the single place that
* turns a 401 into a session invalidation.
*/
class ApiClient {
private token: string | null = null;
constructor(private readonly baseUrl: string) {}
setToken(token: string | null): void {
this.token = token;
}
private async fetch(
method: string,
path: string,
body?: unknown,
): Promise<Response> {
const headers: Record<string, string> = {};
if (body) {
headers['Content-Type'] = 'application/json';
}
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 401) {
throw new ApiError(401, 'Unauthorized');
}
if (!response.ok) {
const text = await response.text().catch(() => 'Unknown error');
throw new ApiError(response.status, text);
}
return response;
}
private async request<T>(
schema: z.ZodType<T>,
method: string,
path: string,
body?: unknown,
): Promise<T> {
const response = await this.fetch(method, path, body);
const json = await response.json();
return schema.parse(json);
}
private async requestVoid(
method: string,
path: string,
body?: unknown,
): Promise<void> {
await this.fetch(method, path, body);
}
// --- Auth ---
async requestCode(data: RequestCodeRequest): Promise<void> {
await this.requestVoid('POST', '/auth/request-code', data);
}
async signIn(data: SignInRequest) {
return this.request(SignInResponseSchema, 'POST', '/auth/sign-in', data);
}
async me() {
return this.request(HumanSchema, 'GET', '/auth/me');
}
async signOut(): Promise<void> {
await this.requestVoid('POST', '/auth/sign-out');
}
async getFirebaseToken() {
return this.request(
FirebaseTokenResponseSchema,
'POST',
'/auth/firebase-token',
);
}
// TODO: security: require passing in the particle id once api deprecates this
async getParticleDownloadUrl(objectId: string): Promise<string> {
const response = await this.fetch('GET', `/particles/${objectId}/download`);
const data = await response.json();
return data.url;
}
// --- Settings ---
async updateSettings(data: {
email_notifications_enabled?: boolean;
}): Promise<void> {
await this.requestVoid('PATCH', '/humans/me/settings', data);
}
// --- Avatar ---
/**
* Upload a new profile picture. The endpoint takes the raw image bytes as
* the request body (not multipart), so we stream the file directly via
* expo-file-system rather than the JSON `fetch` helper.
*/
async uploadAvatar(fileUri: string, mimeType: string): Promise<void> {
const headers: Record<string, string> = { 'Content-Type': mimeType };
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const result = await uploadAsync(
`${this.baseUrl}/humans/me/avatar`,
fileUri,
{
httpMethod: 'PUT',
uploadType: FileSystemUploadType.BINARY_CONTENT,
headers,
},
);
if (result.status === 401) {
throw new ApiError(401, 'Unauthorized');
}
if (result.status < 200 || result.status >= 300) {
throw new ApiError(result.status, result.body || 'Avatar upload failed');
}
}
async deleteAvatar(): Promise<void> {
await this.requestVoid('DELETE', '/humans/me/avatar');
}
async getAvatarDownloadUrl(objectId: string): Promise<string> {
const response = await this.fetch('GET', `/humans/avatar/${objectId}`);
const data = await response.json();
return data.url;
}
// --- Push notification tokens ---
async registerPushToken(data: {
token: string;
platform: 'ios' | 'android';
app_version: string;
}): Promise<void> {
await this.requestVoid('POST', '/humans/me/push-tokens', data);
}
async unregisterPushToken(token: string): Promise<void> {
await this.requestVoid('DELETE', '/humans/me/push-tokens', { token });
}
// --- Depot ---
async prepareUpload(data: PrepareUploadRequest) {
return this.request(
PrepareUploadResponseSchema,
'POST',
'/depot/upload',
data,
);
}
async confirmUpload(objectId: string) {
return this.request(
DepotObjectSchema,
'POST',
`/depot/objects/${objectId}/confirm`,
);
}
// --- Networks ---
async listNetworks() {
return this.request(ListNetworksResponseSchema, 'GET', '/networks');
}
async createNetwork(data: CreateNetworkRequest) {
return this.request(NetworkSchema, 'POST', '/networks', data);
}
async getNetwork(id: string) {
return this.request(NetworkSchema, 'GET', `/networks/${id}`);
}
async addMembers(networkId: string, data: AddMembersRequest): Promise<void> {
await this.requestVoid('POST', `/networks/${networkId}/members`, data);
}
async removeMember(networkId: string, humanId: string): Promise<void> {
await this.requestVoid(
'DELETE',
`/networks/${networkId}/members/${humanId}`,
);
}
// --- Invitations ---
async listNetworkInvitations(networkId: string) {
return this.request(
ListInvitationsResponseSchema,
'GET',
`/networks/${networkId}/invitations`,
);
}
async listMyInvitations() {
return this.request(ListInvitationsResponseSchema, 'GET', '/invitations');
}
async acceptInvitation(data: AcceptInvitationRequest): Promise<void> {
await this.requestVoid('POST', '/invitations/accept', data);
}
async revokeInvitation(
networkId: string,
data: RevokeInvitationRequest,
): Promise<void> {
await this.requestVoid(
'DELETE',
`/networks/${networkId}/invitations`,
data,
);
}
// --- LiveKit ---
async getLivekitToken(networkId: string, streamId: string) {
return this.request(
GetLivekitTokenResponseSchema,
'POST',
'/livekit/token',
{ network_id: networkId, stream_id: streamId },
);
}
// --- Billing (network admin only) ---
async getNetworkBilling(networkId: string) {
return this.request(
BillingStatusSchema,
'GET',
`/networks/${networkId}/billing`,
);
}
async createCheckoutSession(networkId: string, cadence: BillingCadence) {
return this.request(
CheckoutSessionResponseSchema,
'POST',
`/networks/${networkId}/billing/checkout-session`,
{ cadence },
);
}
async createPortalSession(networkId: string) {
return this.request(
PortalSessionResponseSchema,
'POST',
`/networks/${networkId}/billing/portal-session`,
);
}
async getNetworkUsage(networkId: string) {
return this.request(
NetworkUsageSchema,
'GET',
`/networks/${networkId}/usage`,
);
}
}
export const apiClient = new ApiClient(appConfig.orionUrl);