Files
llink/js/mobile/src/features/compose/ComposeDock.tsx
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

371 lines
12 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { Pressable, Text, View } from 'react-native';
import {
ListTodo,
Mic,
Type as TypeIcon,
Video as VideoIcon,
} from 'lucide-react-native';
import * as Haptics from 'expo-haptics';
import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
import { toast } from 'sonner-native';
import { cn } from '@/lib/utils';
import { useEvent } from '@/hooks/use-event';
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
import { useAuthStore } from '@/stores/auth-store';
import {
createTaskParticle,
createTextParticle,
uploadMediaParticle,
} from '@/lib/upload';
import type { ParticlePath } from '@/lib/particle-path';
import {
useStreamComposingBroadcastOptional,
type ComposingMode,
} from '@/features/stream-view/stream-presence-context';
import { TextComposeModal } from './TextComposeModal';
import { TaskComposeSheet } from './TaskComposeSheet';
import { VideoRecordingOverlay } from './VideoRecordingOverlay';
import { AudioRecordingOverlay } from './AudioRecordingOverlay';
import { ReviewSheet } from './ReviewSheet';
type RecordingMode = 'video' | 'audio';
type ComposeUiState =
| { kind: 'idle' }
| { kind: 'recording'; mode: RecordingMode }
| {
kind: 'review';
mode: RecordingMode;
uri: string;
durationMs: number;
}
| {
kind: 'uploading';
mode: RecordingMode;
uri: string;
durationMs: number;
};
interface SubmitMediaParams {
fileUri: string;
mimeType: string;
durationMs: number;
source: 'camera' | 'screen';
}
interface ComposeDockProps {
networkId: string;
targetPath: ParticlePath;
silentPresence?: boolean;
/**
* Whether to show the task compose button. Disabled in the new-stream flow,
* where a stream's first particle must be text or media (a task can't open a
* stream — it's added once the stream exists).
*/
allowTask?: boolean;
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
submitText?: (content: string) => Promise<void>;
/**
* Called with the new particle's id right after it's created on the default
* send path. Not fired when `submitMedia`/`submitText` overrides are supplied,
* since those own the created particle themselves. Lets the stream follow a
* just-sent particle when the user was at the end.
*/
onParticleCreated?: (particleId: string) => void;
}
export function ComposeDock({
networkId,
targetPath,
silentPresence = false,
allowTask = true,
submitMedia,
submitText: submitTextOverride,
onParticleCreated,
}: ComposeDockProps) {
const userId = useAuthStore((s) => s.user?.id);
const [mode, setMode] = useState<RecordingMode>('video');
const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' });
const [textOpen, setTextOpen] = useState(false);
const [taskOpen, setTaskOpen] = useState(false);
const [camPerm, requestCamPerm] = useCameraPermissions();
const [micPerm, requestMicPerm] = useMicrophonePermissions();
// Tell StreamView to fully unmount its expo-video player while we record.
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
const isComposing = ui.kind !== 'idle' || textOpen || taskOpen;
useEffect(() => {
setComposing(isComposing);
return () => setComposing(false);
}, [isComposing, setComposing]);
useComposingBroadcast({ ui, textOpen, taskOpen, silent: silentPresence });
const ensurePermissions = useCallback(
async (forVideo: boolean): Promise<boolean> => {
if (forVideo) {
const cam = camPerm?.granted ? camPerm : await requestCamPerm();
if (!cam.granted) {
toast.error('Camera permission is required to record video.');
return false;
}
}
const mic = micPerm?.granted ? micPerm : await requestMicPerm();
if (!mic.granted) {
toast.error('Microphone permission is required to record.');
return false;
}
return true;
},
[camPerm, micPerm, requestCamPerm, requestMicPerm],
);
const startRecording = useEvent(async () => {
if (ui.kind !== 'idle') return;
const ok = await ensurePermissions(mode === 'video');
if (!ok) return;
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setUi({ kind: 'recording', mode });
});
const handleRecordingComplete = useCallback(
({ uri, durationMs }: { uri: string; durationMs: number }) => {
void Haptics.selectionAsync();
setUi((prev) => {
const m = 'mode' in prev ? prev.mode : mode;
return { kind: 'review', mode: m, uri, durationMs };
});
},
[mode],
);
const handleRecordingCancel = useCallback(() => {
setUi({ kind: 'idle' });
}, []);
const sendReview = useEvent(async () => {
if (ui.kind !== 'review' || !userId) return;
const captured = ui;
setUi({
kind: 'uploading',
mode: captured.mode,
uri: captured.uri,
durationMs: captured.durationMs,
});
try {
const mimeType = captured.mode === 'audio' ? 'audio/mp4' : 'video/mp4';
if (submitMedia) {
await submitMedia({
fileUri: captured.uri,
mimeType,
durationMs: captured.durationMs,
source: 'camera',
});
} else {
const particleId = await uploadMediaParticle({
networkId,
targetPath,
fileUri: captured.uri,
mimeType,
durationMs: captured.durationMs,
source: 'camera',
createdByHumanId: userId,
});
onParticleCreated?.(particleId);
}
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setUi({ kind: 'idle' });
} catch (err) {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
setUi(captured);
throw err;
}
});
const retake = useCallback(() => setUi({ kind: 'idle' }), []);
const cancelReview = useCallback(() => setUi({ kind: 'idle' }), []);
const submitText = useEvent(async (content: string) => {
if (!userId) throw new Error('Not signed in.');
if (submitTextOverride) {
await submitTextOverride(content);
} else {
const particleId = await createTextParticle({
networkId,
targetPath,
content,
createdByHumanId: userId,
});
onParticleCreated?.(particleId);
}
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
});
const submitTask = useEvent(
async ({ title, notes }: { title: string; notes?: string }) => {
if (!userId) throw new Error('Not signed in.');
const particleId = await createTaskParticle({
targetPath,
title,
notes,
createdByHumanId: userId,
});
onParticleCreated?.(particleId);
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
},
);
const dockHidden =
ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording';
return (
<>
{!dockHidden ? (
<View pointerEvents="box-none" className="absolute inset-x-0 bottom-0">
<View
pointerEvents="box-none"
className="flex-row items-center px-8 pb-10"
>
{/* Left and right clusters flex equally so the record button stays
centered regardless of how many side controls are present. */}
<View className="flex-1 flex-row items-center">
<Pressable
onPress={() =>
setMode((m) => (m === 'video' ? 'audio' : 'video'))
}
disabled={ui.kind !== 'idle'}
accessibilityLabel={`Switch to ${
mode === 'video' ? 'audio' : 'video'
} mode`}
className={cn(
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
ui.kind !== 'idle' && 'opacity-40',
)}
>
{mode === 'video' ? (
<VideoIcon color="white" size={20} strokeWidth={1.6} />
) : (
<Mic color="white" size={20} strokeWidth={1.6} />
)}
</Pressable>
</View>
<View className="items-center">
<Pressable
onPress={startRecording}
disabled={ui.kind !== 'idle'}
accessibilityLabel={`Record ${mode}`}
className="h-20 w-20 items-center justify-center rounded-full bg-white"
>
<View className="h-6 w-6 rounded bg-black" />
</Pressable>
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
</View>
<View className="flex-1 flex-row items-center justify-end gap-3">
{allowTask ? (
<Pressable
onPress={() => setTaskOpen(true)}
disabled={ui.kind !== 'idle'}
accessibilityLabel="Create task"
className={cn(
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
ui.kind !== 'idle' && 'opacity-40',
)}
>
<ListTodo color="white" size={20} strokeWidth={1.6} />
</Pressable>
) : null}
<Pressable
onPress={() => setTextOpen(true)}
disabled={ui.kind !== 'idle'}
accessibilityLabel="Compose text"
className={cn(
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
ui.kind !== 'idle' && 'opacity-40',
)}
>
<TypeIcon color="white" size={20} strokeWidth={1.6} />
</Pressable>
</View>
</View>
</View>
) : null}
{ui.kind === 'recording' ? (
ui.mode === 'video' ? (
<VideoRecordingOverlay
onComplete={handleRecordingComplete}
onCancel={handleRecordingCancel}
/>
) : (
<AudioRecordingOverlay
onComplete={handleRecordingComplete}
onCancel={handleRecordingCancel}
/>
)
) : null}
<ReviewSheet
open={ui.kind === 'review' || ui.kind === 'uploading'}
uri={ui.kind === 'review' || ui.kind === 'uploading' ? ui.uri : null}
mode={ui.kind === 'review' || ui.kind === 'uploading' ? ui.mode : null}
durationMs={
ui.kind === 'review' || ui.kind === 'uploading' ? ui.durationMs : 0
}
sending={ui.kind === 'uploading'}
onSend={sendReview}
onRetake={retake}
onCancel={cancelReview}
/>
<TextComposeModal
open={textOpen}
onClose={() => setTextOpen(false)}
onSubmit={submitText}
/>
<TaskComposeSheet
open={taskOpen}
onClose={() => setTaskOpen(false)}
onSubmit={submitTask}
/>
</>
);
}
function useComposingBroadcast({
ui,
textOpen,
taskOpen,
silent,
}: {
ui: ComposeUiState;
textOpen: boolean;
taskOpen: boolean;
silent: boolean;
}) {
// null when the dock is rendered outside a stream (no presence provider).
const broadcast = useStreamComposingBroadcastOptional();
const mode: ComposingMode | null =
ui.kind === 'recording'
? 'recording'
: textOpen || taskOpen
? 'typing'
: null;
useEffect(() => {
if (silent || !broadcast) return;
if (mode) {
broadcast.startComposing(mode);
return () => broadcast?.stopComposing();
}
}, [mode, silent, broadcast]);
}