Files
llink/js/mobile/src/lib/upload.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

221 lines
6.0 KiB
TypeScript

import {
FileSystemUploadType,
getInfoAsync,
uploadAsync,
} from 'expo-file-system/legacy';
import { apiClient } from '@/api/client';
import {
createParticle,
createStreamParticle,
} from '@/lib/firestore-particles';
import {
particlePath,
toFirestoreChildrenPath,
type ParticlePath,
} from '@/lib/particle-path';
interface UploadMediaParticleParams {
networkId: string;
/** Path of the destination container (stream — possibly with sub-segments). */
targetPath: ParticlePath;
fileUri: string;
mimeType: string;
durationMs: number;
source: 'camera' | 'screen';
createdByHumanId: string;
}
/**
* Upload a recorded file and create the corresponding `media` particle in
* Firestore. Order matches desktop's `use-recorder` flow exactly:
* prepareUpload → PUT → confirmUpload → createParticle.
*
* Returns the new particle's id, or throws on any failure (no half-states —
* if any step fails the caller cancels and reports).
*/
export async function uploadMediaParticle({
networkId,
targetPath,
fileUri,
mimeType,
durationMs,
source,
createdByHumanId,
}: UploadMediaParticleParams): Promise<string> {
const info = await getInfoAsync(fileUri);
if (!info.exists || info.size === undefined) {
throw new Error('Recording file disappeared before upload.');
}
const sizeBytes = info.size;
const namePrefix = mimeType.startsWith('audio/') ? 'voice' : 'video';
const ext = extensionFromMime(mimeType);
const name = `${namePrefix}-${Date.now()}${ext}`;
const { object_id, upload_url, upload_headers } =
await apiClient.prepareUpload({
network_id: networkId,
name,
content_type: mimeType,
content_length: sizeBytes,
});
const uploadResult = await uploadAsync(upload_url, fileUri, {
httpMethod: 'PUT',
uploadType: FileSystemUploadType.BINARY_CONTENT,
headers: upload_headers,
});
if (uploadResult.status < 200 || uploadResult.status >= 300) {
throw new Error(`Upload to depot failed (HTTP ${uploadResult.status}).`);
}
await apiClient.confirmUpload(object_id);
const collectionPath = toFirestoreChildrenPath(targetPath);
return createParticle(
collectionPath,
'media',
{
object_id,
mime_type: mimeType,
duration_ms: durationMs,
size_bytes: sizeBytes,
source,
},
createdByHumanId,
);
}
interface CreateTextParticleParams {
networkId: string;
targetPath: ParticlePath;
content: string;
createdByHumanId: string;
}
export async function createTextParticle({
targetPath,
content,
createdByHumanId,
}: CreateTextParticleParams): Promise<string> {
const collectionPath = toFirestoreChildrenPath(targetPath);
return createParticle(collectionPath, 'text', { content }, createdByHumanId);
}
interface CreateTaskParticleParams {
targetPath: ParticlePath;
title: string;
notes?: string;
createdByHumanId: string;
}
/**
* Create a `task` particle. Mirrors createTextParticle — the checklist and
* assignee are left empty and edited inline in the task card afterwards.
*/
export async function createTaskParticle({
targetPath,
title,
notes,
createdByHumanId,
}: CreateTaskParticleParams): Promise<string> {
const collectionPath = toFirestoreChildrenPath(targetPath);
return createParticle(
collectionPath,
'task',
{ title, done: false, ...(notes ? { notes } : {}) },
createdByHumanId,
);
}
function extensionFromMime(mime: string): string {
if (mime === 'video/mp4') return '.mp4';
if (mime === 'video/quicktime') return '.mov';
if (mime === 'audio/mp4') return '.m4a';
if (mime === 'audio/webm') return '.webm';
return '';
}
// Helper kept here so callers can construct a fresh stream's child-path before
// the stream particle has been written.
export function streamChildrenPath(
networkId: string,
streamId: string,
): ParticlePath {
return particlePath(networkId, [streamId]);
}
// --- New-stream flow ---
interface CreateStreamWithFirstParticleParams {
networkId: string;
name: string;
/** ["network:{id}"] for everyone; ["human:{id}", ...] for specific people. */
visibleTo: string[];
createdByHumanId: string;
/** First particle to write into the new stream. Required — empty streams are not useful. */
firstParticle:
| { type: 'text'; content: string }
| {
type: 'media';
fileUri: string;
mimeType: string;
durationMs: number;
source: 'camera' | 'screen';
};
}
interface CreateStreamWithFirstParticleResult {
streamId: string;
}
/**
* Create a top-level stream particle plus its first child particle, in that
* order. Mirrors desktop's "create new stream" submit path (compose-overlay
* §handleStreamSubmit). On any failure the caller is responsible for retry —
* we don't roll back the stream particle on child failure because Firestore
* doesn't expose a multi-write transaction across these subcollections, and
* an empty stream is harmless (the user can retry composing into it).
*/
export async function createStreamWithFirstParticle({
networkId,
name,
visibleTo,
createdByHumanId,
firstParticle,
}: CreateStreamWithFirstParticleParams): Promise<CreateStreamWithFirstParticleResult> {
// 1. The stream particle goes at the network root.
const rootChildrenPath = toFirestoreChildrenPath(particlePath(networkId, []));
const streamId = await createStreamParticle(
rootChildrenPath,
{ name },
createdByHumanId,
visibleTo,
);
const streamPath = particlePath(networkId, [streamId]);
// 2. The first child goes inside the new stream.
if (firstParticle.type === 'text') {
await createTextParticle({
networkId,
targetPath: streamPath,
content: firstParticle.content,
createdByHumanId,
});
} else {
await uploadMediaParticle({
networkId,
targetPath: streamPath,
fileUri: firstParticle.fileUri,
mimeType: firstParticle.mimeType,
durationMs: firstParticle.durationMs,
source: firstParticle.source,
createdByHumanId,
});
}
return { streamId };
}