* 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]>
314 lines
7.8 KiB
TypeScript
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);
|