* 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]>
210 lines
6.3 KiB
TypeScript
210 lines
6.3 KiB
TypeScript
import { useMemo, useState } from 'react';
|
||
import {
|
||
KeyboardAvoidingView,
|
||
Platform,
|
||
Pressable,
|
||
Text,
|
||
TextInput,
|
||
View,
|
||
} from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { StatusBar } from 'expo-status-bar';
|
||
import { ChevronRight, Globe, Lock, X } from 'lucide-react-native';
|
||
import { toast } from 'sonner-native';
|
||
import { ComposeDock } from '@/features/compose/ComposeDock';
|
||
import { useNetwork } from '@/hooks/use-networks';
|
||
import { particlePath } from '@/lib/particle-path';
|
||
import { generateRandomName } from '@/lib/random-name';
|
||
import { createStreamWithFirstParticle } from '@/lib/upload';
|
||
import { toUserMessage } from '@/lib/errors';
|
||
import {
|
||
buildNetworkVisibility,
|
||
parseVisibleTo,
|
||
} from '@/lib/stream-visibility';
|
||
import { useAuthStore } from '@/stores/auth-store';
|
||
import type { RootStackScreenProps } from '@/navigation/types';
|
||
import { VisibilityPickerSheet } from './VisibilityPickerSheet';
|
||
|
||
const STREAM_NAME_MAX = 60;
|
||
|
||
/**
|
||
* Top-level stream creation. The user names the stream, picks visibility, and
|
||
* composes the first particle on one screen — desktop's compose-overlay flow
|
||
* collapsed into a touch-native single page.
|
||
*/
|
||
export function NewStreamScreen({
|
||
route,
|
||
navigation,
|
||
}: RootStackScreenProps<'NewStream'>) {
|
||
const { networkId } = route.params;
|
||
const network = useNetwork(networkId);
|
||
const userId = useAuthStore((s) => s.user?.id);
|
||
|
||
const suggestion = useMemo(() => generateRandomName(), []);
|
||
const [name, setName] = useState('');
|
||
const [visibleTo, setVisibleTo] = useState<string[]>(() =>
|
||
buildNetworkVisibility(networkId),
|
||
);
|
||
const [pickerOpen, setPickerOpen] = useState(false);
|
||
|
||
const effectiveName = name.trim() || suggestion;
|
||
|
||
const handleStreamCreated = (streamId: string) => {
|
||
navigation.replace('StreamView', { networkId, streamId });
|
||
};
|
||
|
||
const submitText = async (content: string) => {
|
||
if (!userId) throw new Error('Not signed in.');
|
||
try {
|
||
const { streamId } = await createStreamWithFirstParticle({
|
||
networkId,
|
||
name: effectiveName,
|
||
visibleTo,
|
||
createdByHumanId: userId,
|
||
firstParticle: { type: 'text', content },
|
||
});
|
||
handleStreamCreated(streamId);
|
||
} catch (err) {
|
||
toast.error(toUserMessage(err));
|
||
throw err;
|
||
}
|
||
};
|
||
|
||
const submitMedia = async ({
|
||
fileUri,
|
||
mimeType,
|
||
durationMs,
|
||
source,
|
||
}: {
|
||
fileUri: string;
|
||
mimeType: string;
|
||
durationMs: number;
|
||
source: 'camera' | 'screen';
|
||
}) => {
|
||
if (!userId) throw new Error('Not signed in.');
|
||
try {
|
||
const { streamId } = await createStreamWithFirstParticle({
|
||
networkId,
|
||
name: effectiveName,
|
||
visibleTo,
|
||
createdByHumanId: userId,
|
||
firstParticle: {
|
||
type: 'media',
|
||
fileUri,
|
||
mimeType,
|
||
durationMs,
|
||
source,
|
||
},
|
||
});
|
||
handleStreamCreated(streamId);
|
||
} catch (err) {
|
||
toast.error(toUserMessage(err));
|
||
throw err;
|
||
}
|
||
};
|
||
|
||
const placeholderPath = particlePath(networkId, []);
|
||
|
||
const visibility = parseVisibleTo(visibleTo, networkId);
|
||
const visibleSummary =
|
||
visibility.mode === 'network'
|
||
? `Everyone in ${network?.name ?? 'this network'}`
|
||
: `${visibility.humanIds.length} ${
|
||
visibility.humanIds.length === 1 ? 'person' : 'people'
|
||
}`;
|
||
|
||
return (
|
||
<View className="flex-1 bg-black">
|
||
<StatusBar style="light" />
|
||
|
||
<SafeAreaView edges={['top']}>
|
||
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
|
||
<Pressable
|
||
onPress={() => navigation.goBack()}
|
||
hitSlop={12}
|
||
accessibilityLabel="Cancel"
|
||
>
|
||
<X color="white" size={22} strokeWidth={1.8} />
|
||
</Pressable>
|
||
<Text className="text-white text-base font-semibold">New stream</Text>
|
||
<View style={{ width: 22 }} />
|
||
</View>
|
||
</SafeAreaView>
|
||
|
||
<KeyboardAvoidingView
|
||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||
className="flex-1"
|
||
>
|
||
<View className="flex-1 px-6 pt-4">
|
||
<Text className="text-white/60 text-xs uppercase tracking-wide mb-2">
|
||
Name
|
||
</Text>
|
||
<TextInput
|
||
value={name}
|
||
onChangeText={(v) => setName(v.slice(0, STREAM_NAME_MAX))}
|
||
placeholder={suggestion}
|
||
placeholderTextColor="rgba(255,255,255,0.35)"
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
maxLength={STREAM_NAME_MAX}
|
||
className="bg-white/10 rounded-xl px-4 py-3 text-white text-lg"
|
||
/>
|
||
|
||
<Text className="text-white/60 text-xs uppercase tracking-wide mt-6 mb-2">
|
||
Visible to
|
||
</Text>
|
||
<Pressable
|
||
onPress={() => setPickerOpen(true)}
|
||
className="bg-white/10 active:bg-white/15 rounded-xl px-4 py-3 flex-row items-center gap-3"
|
||
>
|
||
{visibility.mode === 'network' ? (
|
||
<Globe
|
||
color="rgba(255,255,255,0.7)"
|
||
size={18}
|
||
strokeWidth={1.6}
|
||
/>
|
||
) : (
|
||
<Lock color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
|
||
)}
|
||
<Text className="text-white text-base flex-1" numberOfLines={1}>
|
||
{visibleSummary}
|
||
</Text>
|
||
<ChevronRight
|
||
color="rgba(255,255,255,0.5)"
|
||
size={18}
|
||
strokeWidth={1.6}
|
||
/>
|
||
</Pressable>
|
||
|
||
<View className="mt-6 px-1">
|
||
<Text className="text-white/50 text-sm">
|
||
Hold the button below to record a voice or video message — that’s
|
||
the first particle in your new stream.
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</KeyboardAvoidingView>
|
||
|
||
<ComposeDock
|
||
networkId={networkId}
|
||
targetPath={placeholderPath}
|
||
silentPresence
|
||
allowTask={false}
|
||
submitMedia={submitMedia}
|
||
submitText={submitText}
|
||
/>
|
||
|
||
<VisibilityPickerSheet
|
||
open={pickerOpen}
|
||
onClose={() => setPickerOpen(false)}
|
||
networkId={networkId}
|
||
networkName={network?.name}
|
||
humans={network?.humans ?? []}
|
||
selfHumanId={userId}
|
||
visibleTo={visibleTo}
|
||
onChange={setVisibleTo}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|