* 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]>
371 lines
12 KiB
TypeScript
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]);
|
|
}
|