From d49a7146e3fbb48e730f9f346c6666902a21854f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:48:09 +0000 Subject: [PATCH 01/12] 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) Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV --- .../features/networks/CreateNetworkSheet.tsx | 90 ++++++++++++++++ js/mobile/src/features/networks/Drawer.tsx | 14 ++- .../features/networks/NetworkListScreen.tsx | 100 ++++++++++++++++-- .../src/features/settings/SettingsScreen.tsx | 91 +++++++++++++++- js/mobile/src/hooks/use-invitations.ts | 39 +++++++ 5 files changed, 319 insertions(+), 15 deletions(-) create mode 100644 js/mobile/src/features/networks/CreateNetworkSheet.tsx create mode 100644 js/mobile/src/hooks/use-invitations.ts diff --git a/js/mobile/src/features/networks/CreateNetworkSheet.tsx b/js/mobile/src/features/networks/CreateNetworkSheet.tsx new file mode 100644 index 0000000..6c892bf --- /dev/null +++ b/js/mobile/src/features/networks/CreateNetworkSheet.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { toast } from 'sonner-native'; +import { BottomSheet } from '@/components/BottomSheet'; +import { useCreateNetwork } from '@/hooks/use-invitations'; +import { toUserMessage } from '@/lib/errors'; +import { cn } from '@/lib/utils'; + +interface CreateNetworkSheetProps { + open: boolean; + onClose: () => void; + /** Called with the new network's id once creation succeeds. */ + onCreated?: (networkId: string) => void; +} + +/** + * Minimal "name your network" sheet. Mirrors desktop's create-network flow in + * `network-selector.tsx` — single text field, creator becomes admin. + */ +export function CreateNetworkSheet({ + open, + onClose, + onCreated, +}: CreateNetworkSheetProps) { + const [name, setName] = useState(''); + const createNetwork = useCreateNetwork(); + + // Reset the field each time the sheet opens fresh. + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); + if (open) setName(''); + } + + const trimmed = name.trim(); + const canCreate = trimmed.length > 0 && !createNetwork.isPending; + + const handleCreate = async () => { + if (!canCreate) return; + try { + const network = await createNetwork.mutateAsync({ name: trimmed }); + onClose(); + onCreated?.(network.id); + } catch (err) { + toast.error(toUserMessage(err)); + } + }; + + return ( + + + New network + + You’ll be the admin and can invite people next. + + + + + + + {createNetwork.isPending ? 'Creating…' : 'Create network'} + + + + + ); +} diff --git a/js/mobile/src/features/networks/Drawer.tsx b/js/mobile/src/features/networks/Drawer.tsx index 4c03114..91b686f 100644 --- a/js/mobile/src/features/networks/Drawer.tsx +++ b/js/mobile/src/features/networks/Drawer.tsx @@ -26,7 +26,12 @@ interface DrawerProps { onNavigateSettings: () => void; } -export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) { +export function Drawer({ + open, + onClose, + onNavigateAccount, + onNavigateSettings, +}: DrawerProps) { // Lazy-init so each Animated.Value is created once; the setters are never // called — the values are mutated internally by the native driver. const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH)); @@ -114,6 +119,13 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) { onNavigateAccount(); }} /> + { + onClose(); + onNavigateSettings(); + }} + /> diff --git a/js/mobile/src/features/networks/NetworkListScreen.tsx b/js/mobile/src/features/networks/NetworkListScreen.tsx index 8473932..6c2b50c 100644 --- a/js/mobile/src/features/networks/NetworkListScreen.tsx +++ b/js/mobile/src/features/networks/NetworkListScreen.tsx @@ -8,20 +8,26 @@ import { View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import type { Network } from '@/api/types'; +import { Plus } from 'lucide-react-native'; +import { toast } from 'sonner-native'; +import type { Invitation, Network } from '@/api/types'; import { useNetworks } from '@/hooks/use-networks'; +import { useAcceptInvitation, useMyInvitations } from '@/hooks/use-invitations'; import { useAuthStore } from '@/stores/auth-store'; import { toUserMessage } from '@/lib/errors'; import type { RootStackScreenProps } from '@/navigation/types'; import { FlowyLogo } from '@/components/FlowyLogo'; import { ListSeparator } from '@/components/ListSeparator'; import { Drawer } from './Drawer'; +import { CreateNetworkSheet } from './CreateNetworkSheet'; export function NetworkListScreen({ navigation, }: RootStackScreenProps<'NetworkList'>) { const [drawerOpen, setDrawerOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); const { data, isLoading, refetch, error } = useNetworks(); + const { data: invitations, refetch: refetchInvitations } = useMyInvitations(); const user = useAuthStore((s) => s.user); const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??'; @@ -32,11 +38,13 @@ export function NetworkListScreen({ const onRefresh = useCallback(async () => { setRefreshing(true); try { - await refetch(); + await Promise.all([refetch(), refetchInvitations()]); } finally { setRefreshing(false); } - }, [refetch]); + }, [refetch, refetchInvitations]); + + const pendingInvitations = invitations ?? []; return ( @@ -51,7 +59,13 @@ export function NetworkListScreen({ - + setCreateOpen(true)} + accessibilityLabel="Create network" + className="bg-muted h-9 w-9 items-center justify-center rounded-full" + > + + {isLoading ? ( @@ -67,16 +81,21 @@ export function NetworkListScreen({ Retry - ) : !data || data.length === 0 ? ( - + ) : (!data || data.length === 0) && pendingInvitations.length === 0 ? ( + setCreateOpen(true)} /> ) : ( item.id} refreshControl={ } ItemSeparatorComponent={ListSeparator} + ListHeaderComponent={ + pendingInvitations.length > 0 ? ( + + ) : null + } renderItem={({ item }) => ( navigation.navigate('Account')} onNavigateSettings={() => navigation.navigate('Settings')} /> + + setCreateOpen(false)} + onCreated={(networkId) => + navigation.navigate('StreamList', { networkId }) + } + /> ); } +function InvitationsSection({ invitations }: { invitations: Invitation[] }) { + return ( + + + Invitations + + {invitations.map((invitation) => ( + + ))} + + ); +} + +function InvitationCard({ invitation }: { invitation: Invitation }) { + const acceptInvitation = useAcceptInvitation(); + + const handleAccept = async () => { + try { + await acceptInvitation.mutateAsync(invitation.network_id); + } catch (err) { + toast.error(toUserMessage(err)); + } + }; + + return ( + + + + {invitation.network_name} + + + You’ve been invited to join + + + + + {acceptInvitation.isPending ? 'Joining…' : 'Accept'} + + + + ); +} + function NetworkCard({ network, onPress, @@ -124,15 +198,23 @@ function NetworkCard({ ); } -function EmptyState() { +function EmptyState({ onCreate }: { onCreate: () => void }) { return ( You aren’t in any networks yet. - Ask a friend for an invite, or create one on desktop. + Create one to get started, or ask a friend for an invite. + + + Create a network + + ); } diff --git a/js/mobile/src/features/settings/SettingsScreen.tsx b/js/mobile/src/features/settings/SettingsScreen.tsx index 6b71a6e..ffa1505 100644 --- a/js/mobile/src/features/settings/SettingsScreen.tsx +++ b/js/mobile/src/features/settings/SettingsScreen.tsx @@ -1,10 +1,47 @@ -import { Pressable, Text, View } from 'react-native'; +import { useState } from 'react'; +import { Pressable, Switch, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import Constants from 'expo-constants'; +import { apiClient } from '@/api/client'; +import { useAuthStore } from '@/stores/auth-store'; +import { logError, toUserMessage } from '@/lib/errors'; +import { toast } from 'sonner-native'; import type { RootStackScreenProps } from '@/navigation/types'; export function SettingsScreen({ navigation, }: RootStackScreenProps<'Settings'>) { + const user = useAuthStore((s) => s.user); + const signOut = useAuthStore((s) => s.signOut); + const isSigningOut = useAuthStore((s) => s.isSigningOut); + const [emailNotifications, setEmailNotifications] = useState( + user?.email_notifications_enabled ?? true, + ); + const version = Constants.expoConfig?.version ?? '—'; + + // Optimistic toggle — flip the local + auth-store state immediately, roll + // back on failure. Mirrors desktop's settings-page handler. + const handleToggleEmailNotifications = async (checked: boolean) => { + setEmailNotifications(checked); + useAuthStore.setState((state) => ({ + user: state.user + ? { ...state.user, email_notifications_enabled: checked } + : null, + })); + try { + await apiClient.updateSettings({ email_notifications_enabled: checked }); + } catch (err) { + setEmailNotifications(!checked); + useAuthStore.setState((state) => ({ + user: state.user + ? { ...state.user, email_notifications_enabled: !checked } + : null, + })); + toast.error(toUserMessage(err)); + logError(err, { scope: 'settings.emailNotifications' }); + } + }; + return ( @@ -17,11 +54,55 @@ export function SettingsScreen({ - - - Theme, notifications, and account preferences land here later. - + + + + + Email notifications + + + + + + + + Version + {version} + + + + + void signOut()} + disabled={isSigningOut} + className="px-4 py-3 active:bg-accent rounded-xl" + > + + {isSigningOut ? 'Signing out…' : 'Sign out'} + + + ); } + +function SettingsGroup({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + + {children} + + ); +} diff --git a/js/mobile/src/hooks/use-invitations.ts b/js/mobile/src/hooks/use-invitations.ts new file mode 100644 index 0000000..f54a129 --- /dev/null +++ b/js/mobile/src/hooks/use-invitations.ts @@ -0,0 +1,39 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { CreateNetworkRequest } from '@/api/types'; + +/** Invitations addressed to the signed-in user's email. */ +export function useMyInvitations() { + return useQuery({ + queryKey: ['invitations'], + queryFn: () => apiClient.listMyInvitations(), + meta: { toastOnError: true }, + }); +} + +/** + * Accept a pending invitation, then refresh both the networks list (the user + * is now a member) and the invitations list (the invite is consumed). + */ +export function useAcceptInvitation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (networkId: string) => + apiClient.acceptInvitation({ network_id: networkId }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['networks'] }); + void queryClient.invalidateQueries({ queryKey: ['invitations'] }); + }, + }); +} + +/** Create a network; the creator becomes its admin and first member. */ +export function useCreateNetwork() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateNetworkRequest) => apiClient.createNetwork(data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['networks'] }); + }, + }); +} -- 2.54.0 From 6885ca355b9874630b5af827d14e23e74256f0b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:53:31 +0000 Subject: [PATCH 02/12] mobile: network member management and avatars (parity phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV --- js/mobile/app.config.ts | 7 + js/mobile/package.json | 1 + js/mobile/src/api/client.ts | 40 ++++ js/mobile/src/api/types.ts | 1 + js/mobile/src/components/Avatar.tsx | 24 +- .../network-settings/AddMembersSheet.tsx | 145 ++++++++++++ .../NetworkSettingsScreen.tsx | 213 ++++++++++++++++++ .../src/features/settings/AccountScreen.tsx | 101 ++++++++- .../src/features/streams/StreamListScreen.tsx | 23 +- js/mobile/src/hooks/use-avatar-url.ts | 20 ++ js/mobile/src/hooks/use-member-management.ts | 53 +++++ js/mobile/src/navigation/RootNavigator.tsx | 6 + js/mobile/src/navigation/types.ts | 1 + js/mobile/src/stores/auth-store.ts | 11 + js/mobile/yarn.lock | 12 + 15 files changed, 646 insertions(+), 12 deletions(-) create mode 100644 js/mobile/src/features/network-settings/AddMembersSheet.tsx create mode 100644 js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx create mode 100644 js/mobile/src/hooks/use-avatar-url.ts create mode 100644 js/mobile/src/hooks/use-member-management.ts diff --git a/js/mobile/app.config.ts b/js/mobile/app.config.ts index 16403ec..0b077c3 100644 --- a/js/mobile/app.config.ts +++ b/js/mobile/app.config.ts @@ -55,6 +55,13 @@ const config: ExpoConfig = { 'Flowy uses your microphone to record voice messages.', }, ], + [ + 'expo-image-picker', + { + photosPermission: + 'Flowy uses your photo library to set your profile picture.', + }, + ], [ 'expo-notifications', { diff --git a/js/mobile/package.json b/js/mobile/package.json index 17be350..ced1e56 100644 --- a/js/mobile/package.json +++ b/js/mobile/package.json @@ -31,6 +31,7 @@ "expo-device": "~8.0.10", "expo-file-system": "~19.0.16", "expo-haptics": "~15.0.7", + "expo-image-picker": "~17.0.8", "expo-notifications": "~0.32.17", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", diff --git a/js/mobile/src/api/client.ts b/js/mobile/src/api/client.ts index 27da3e9..a6bbcd4 100644 --- a/js/mobile/src/api/client.ts +++ b/js/mobile/src/api/client.ts @@ -1,3 +1,4 @@ +import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy'; import { appConfig } from '@/config/env'; import { ApiError } from '@/lib/errors'; import type { z } from 'zod'; @@ -136,6 +137,45 @@ class ApiClient { 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 { + const headers: Record = { '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 { + await this.requestVoid('DELETE', '/humans/me/avatar'); + } + + async getAvatarDownloadUrl(objectId: string): Promise { + const response = await this.fetch('GET', `/humans/avatar/${objectId}`); + const data = await response.json(); + return data.url; + } + // --- Push notification tokens --- async registerPushToken(data: { diff --git a/js/mobile/src/api/types.ts b/js/mobile/src/api/types.ts index 7410cbb..ab63df4 100644 --- a/js/mobile/src/api/types.ts +++ b/js/mobile/src/api/types.ts @@ -6,6 +6,7 @@ export const HumanSchema = z.object({ email: z.string().email(), email_prefix: z.string(), email_notifications_enabled: z.boolean(), + avatar_object_id: z.string().nullable().optional(), }); export type Human = z.infer; diff --git a/js/mobile/src/components/Avatar.tsx b/js/mobile/src/components/Avatar.tsx index 8655682..d52df07 100644 --- a/js/mobile/src/components/Avatar.tsx +++ b/js/mobile/src/components/Avatar.tsx @@ -1,5 +1,6 @@ -import { Text, View } from 'react-native'; +import { Image, Text, View } from 'react-native'; import type { Human } from '@/api/types'; +import { useAvatarUrl } from '@/hooks/use-avatar-url'; import { resolveHumanDisplay } from '@/lib/humans'; import { cn } from '@/lib/utils'; @@ -23,9 +24,10 @@ const sizeMap: Record = { }; /** - * Initials avatar with optional online ring (green) and an optional outer - * stack ring used to visually separate overlapping avatars on a busy chrome. - * Matches desktop's avatar + presence pattern (`ring-2 ring-green-500`). + * Human avatar: renders the profile picture when one is set (resolved to a + * signed URL via React Query), otherwise initials. Supports an optional online + * ring (green) and an outer stack separator ring used to keep overlapping + * avatars distinct on busy chrome. Matches desktop's avatar + presence pattern. */ export function Avatar({ humanId, @@ -36,12 +38,14 @@ export function Avatar({ className, }: AvatarProps) { const { initials } = resolveHumanDisplay(humanId, humans); + const human = humanId ? humans?.find((h) => h.id === humanId) : undefined; + const avatarUrl = useAvatarUrl(human?.avatar_object_id); const dims = sizeMap[size]; return ( - - {initials} - + {avatarUrl ? ( + + ) : ( + + {initials} + + )} ); } diff --git a/js/mobile/src/features/network-settings/AddMembersSheet.tsx b/js/mobile/src/features/network-settings/AddMembersSheet.tsx new file mode 100644 index 0000000..9427c01 --- /dev/null +++ b/js/mobile/src/features/network-settings/AddMembersSheet.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { toast } from 'sonner-native'; +import { BottomSheet } from '@/components/BottomSheet'; +import { useAddMembers } from '@/hooks/use-member-management'; +import { toUserMessage } from '@/lib/errors'; +import { cn } from '@/lib/utils'; + +interface AddMembersSheetProps { + open: boolean; + onClose: () => void; + networkId: string; +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Invite people to a network by email. Mirrors desktop's add-members-dialog — + * accepts one email at a time (comma/space/enter to commit), shows chips, and + * submits the batch via `addMembers`. + */ +export function AddMembersSheet({ + open, + onClose, + networkId, +}: AddMembersSheetProps) { + const [draft, setDraft] = useState(''); + const [emails, setEmails] = useState([]); + const addMembers = useAddMembers(networkId); + + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); + if (open) { + setDraft(''); + setEmails([]); + } + } + + const commitDraft = (): string[] => { + const candidate = draft.trim().toLowerCase().replace(/,$/, ''); + if (!candidate) return emails; + if (!EMAIL_RE.test(candidate)) { + toast.error('Enter a valid email address'); + return emails; + } + if (emails.includes(candidate)) { + setDraft(''); + return emails; + } + const next = [...emails, candidate]; + setEmails(next); + setDraft(''); + return next; + }; + + const removeEmail = (email: string) => + setEmails((prev) => prev.filter((e) => e !== email)); + + const handleSubmit = async () => { + const finalEmails = commitDraft(); + if (finalEmails.length === 0) return; + try { + await addMembers.mutateAsync(finalEmails); + toast.success( + finalEmails.length === 1 + ? 'Invitation sent' + : `${finalEmails.length} invitations sent`, + ); + onClose(); + } catch (err) { + toast.error(toUserMessage(err)); + } + }; + + const canSubmit = + !addMembers.isPending && + (emails.length > 0 || EMAIL_RE.test(draft.trim().toLowerCase())); + + return ( + + + Add members + + Existing users join right away; others get an email invite. + + + {emails.length > 0 ? ( + + {emails.map((email) => ( + removeEmail(email)} + className="flex-row items-center bg-white/10 rounded-full pl-3 pr-2 py-1" + > + {email} + × + + ))} + + ) : null} + + { + if (text.endsWith(',') || text.endsWith(' ')) { + setDraft(text); + commitDraft(); + } else { + setDraft(text); + } + }} + placeholder="name@example.com" + placeholderTextColor="rgba(255,255,255,0.4)" + autoFocus + autoCapitalize="none" + autoCorrect={false} + keyboardType="email-address" + returnKeyType="done" + onSubmitEditing={() => commitDraft()} + editable={!addMembers.isPending} + className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4" + /> + + + + {addMembers.isPending ? 'Sending…' : 'Send invites'} + + + + + ); +} diff --git a/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx b/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx new file mode 100644 index 0000000..0331a67 --- /dev/null +++ b/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react'; +import { Alert, Pressable, ScrollView, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Mail, Shield, UserPlus, X } from 'lucide-react-native'; +import { toast } from 'sonner-native'; +import type { Human } from '@/api/types'; +import { useNetwork } from '@/hooks/use-networks'; +import { + useNetworkInvitations, + useRemoveMember, + useRevokeInvitation, +} from '@/hooks/use-member-management'; +import { useAuthStore } from '@/stores/auth-store'; +import { Avatar } from '@/components/Avatar'; +import { toUserMessage } from '@/lib/errors'; +import type { RootStackScreenProps } from '@/navigation/types'; +import { AddMembersSheet } from './AddMembersSheet'; + +export function NetworkSettingsScreen({ + route, + navigation, +}: RootStackScreenProps<'NetworkSettings'>) { + const { networkId } = route.params; + const network = useNetwork(networkId); + const { data: invitations, error: invitationsError } = + useNetworkInvitations(networkId); + const currentUserId = useAuthStore((s) => s.user?.id); + const isAdmin = !!currentUserId && network?.admin_human.id === currentUserId; + const [addOpen, setAddOpen] = useState(false); + + const removeMember = useRemoveMember(networkId); + const members = network?.humans ?? []; + const pending = invitations ?? []; + + const handleRemove = (human: Human) => { + Alert.alert( + `Remove ${human.email_prefix}?`, + "They'll lose access to this network's streams and files. Content they posted stays in the network.", + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: async () => { + try { + await removeMember.mutateAsync(human.id); + toast.success(`Removed ${human.email}`); + } catch (err) { + toast.error(toUserMessage(err)); + } + }, + }, + ], + ); + }; + + return ( + + + navigation.goBack()} className="px-2 py-1"> + + + + {network?.name ?? 'Network'} + + + + + + + + + Members + + + {members.length} {members.length === 1 ? 'member' : 'members'} + + + {isAdmin ? ( + setAddOpen(true)} + className="flex-row items-center bg-primary rounded-full px-3 py-2" + > + + + Add + + + ) : null} + + + + {members.map((human) => { + const isRowAdmin = human.id === network?.admin_human.id; + const canRemove = + isAdmin && !isRowAdmin && human.id !== currentUserId; + return ( + + + + + {human.email_prefix} + + + {human.email} + + + {isRowAdmin ? ( + + + + Admin + + + ) : null} + {canRemove ? ( + handleRemove(human)} + hitSlop={8} + className="p-1" + accessibilityLabel={`Remove ${human.email}`} + > + + + ) : null} + + ); + })} + + + {isAdmin ? ( + + + Pending invitations + + {invitationsError ? ( + + Couldn’t load pending invitations. + + ) : pending.length === 0 ? ( + + No pending invitations. + + ) : ( + + {pending.map((inv) => ( + + ))} + + )} + + ) : null} + + + {isAdmin ? ( + setAddOpen(false)} + networkId={networkId} + /> + ) : null} + + ); +} + +function PendingInvitationRow({ + email, + networkId, +}: { + email: string; + networkId: string; +}) { + const revokeInvitation = useRevokeInvitation(networkId); + + const handleRevoke = async () => { + try { + await revokeInvitation.mutateAsync(email); + toast.success(`Invitation to ${email} revoked`); + } catch (err) { + toast.error(toUserMessage(err)); + } + }; + + return ( + + + + + + + {email} + + Pending + + + + + + ); +} diff --git a/js/mobile/src/features/settings/AccountScreen.tsx b/js/mobile/src/features/settings/AccountScreen.tsx index e57f93a..bd28e4e 100644 --- a/js/mobile/src/features/settings/AccountScreen.tsx +++ b/js/mobile/src/features/settings/AccountScreen.tsx @@ -1,10 +1,77 @@ -import { Pressable, Text, View } from 'react-native'; +import { useState } from 'react'; +import { + ActivityIndicator, + Alert, + Image, + Pressable, + Text, + View, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import * as ImagePicker from 'expo-image-picker'; +import { Camera } from 'lucide-react-native'; +import { toast } from 'sonner-native'; +import { apiClient } from '@/api/client'; import { useAuthStore } from '@/stores/auth-store'; +import { useAvatarUrl } from '@/hooks/use-avatar-url'; +import { toUserMessage } from '@/lib/errors'; import type { RootStackScreenProps } from '@/navigation/types'; export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) { const user = useAuthStore((s) => s.user); + const refreshUser = useAuthStore((s) => s.refreshUser); + const avatarUrl = useAvatarUrl(user?.avatar_object_id); + const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? '?'; + const [busy, setBusy] = useState(false); + + const pickAndUpload = async () => { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + toast.error('Photo library access is needed to set an avatar.'); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + }); + if (result.canceled) return; + const asset = result.assets[0]; + if (!asset) return; + + setBusy(true); + try { + await apiClient.uploadAvatar(asset.uri, asset.mimeType ?? 'image/jpeg'); + await refreshUser(); + toast.success('Avatar updated'); + } catch (err) { + toast.error(toUserMessage(err)); + } finally { + setBusy(false); + } + }; + + const removeAvatar = () => { + Alert.alert('Remove avatar?', 'Your initials will be shown instead.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: async () => { + setBusy(true); + try { + await apiClient.deleteAvatar(); + await refreshUser(); + } catch (err) { + toast.error(toUserMessage(err)); + } finally { + setBusy(false); + } + }, + }, + ]); + }; return ( @@ -18,7 +85,37 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) { - + + + + {busy ? ( + + ) : avatarUrl ? ( + + ) : ( + + {initials} + + )} + + + + + + + {user?.avatar_object_id ? ( + + Remove photo + + ) : null} + + + diff --git a/js/mobile/src/features/streams/StreamListScreen.tsx b/js/mobile/src/features/streams/StreamListScreen.tsx index 1a8a3ad..280c429 100644 --- a/js/mobile/src/features/streams/StreamListScreen.tsx +++ b/js/mobile/src/features/streams/StreamListScreen.tsx @@ -6,6 +6,7 @@ import { View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { Settings as SettingsIcon } from 'lucide-react-native'; import { ListSeparator } from '@/components/ListSeparator'; import { toUserMessage } from '@/lib/errors'; import { particlePath } from '@/lib/particle-path'; @@ -32,6 +33,9 @@ export function StreamListScreen({
navigation.goBack()} + onOpenSettings={() => + navigation.navigate('NetworkSettings', { networkId }) + } /> {error ? ( @@ -67,7 +71,15 @@ export function StreamListScreen({ ); } -function Header({ title, onBack }: { title: string; onBack: () => void }) { +function Header({ + title, + onBack, + onOpenSettings, +}: { + title: string; + onBack: () => void; + onOpenSettings: () => void; +}) { return ( void }) { > {title} - + + + ); } diff --git a/js/mobile/src/hooks/use-avatar-url.ts b/js/mobile/src/hooks/use-avatar-url.ts new file mode 100644 index 0000000..3360359 --- /dev/null +++ b/js/mobile/src/hooks/use-avatar-url.ts @@ -0,0 +1,20 @@ +import { skipToken, useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; + +/** + * Resolve an avatar object id to a signed download URL. React Query handles + * caching and de-duping, so many avatars sharing an id make a single request. + * Mirrors desktop's use-avatar-url. + */ +export function useAvatarUrl( + objectId: string | null | undefined, +): string | undefined { + const { data } = useQuery({ + queryKey: ['avatar-url', objectId], + queryFn: objectId + ? () => apiClient.getAvatarDownloadUrl(objectId) + : skipToken, + staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h + }); + return data; +} diff --git a/js/mobile/src/hooks/use-member-management.ts b/js/mobile/src/hooks/use-member-management.ts new file mode 100644 index 0000000..fe426db --- /dev/null +++ b/js/mobile/src/hooks/use-member-management.ts @@ -0,0 +1,53 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; + +/** Pending invitations sent for a network (member-visible). */ +export function useNetworkInvitations(networkId: string) { + return useQuery({ + queryKey: ['network-invitations', networkId], + queryFn: () => apiClient.listNetworkInvitations(networkId), + }); +} + +/** + * Invite people by email. Existing users join directly; others get a pending + * invitation. Refreshes both the network (new members) and its invitation list. + */ +export function useAddMembers(networkId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (emails: string[]) => + apiClient.addMembers(networkId, { email_addresses: emails }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['networks'] }); + void queryClient.invalidateQueries({ + queryKey: ['network-invitations', networkId], + }); + }, + }); +} + +/** Remove a member from the network (admin only). */ +export function useRemoveMember(networkId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (humanId: string) => apiClient.removeMember(networkId, humanId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['networks'] }); + }, + }); +} + +/** Revoke a pending invitation by email. */ +export function useRevokeInvitation(networkId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (email: string) => + apiClient.revokeInvitation(networkId, { email }), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ['network-invitations', networkId], + }); + }, + }); +} diff --git a/js/mobile/src/navigation/RootNavigator.tsx b/js/mobile/src/navigation/RootNavigator.tsx index 4cbb217..ac04756 100644 --- a/js/mobile/src/navigation/RootNavigator.tsx +++ b/js/mobile/src/navigation/RootNavigator.tsx @@ -7,6 +7,7 @@ import { StreamListScreen } from '@/features/streams/StreamListScreen'; import { NewStreamScreen } from '@/features/streams/NewStreamScreen'; import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen'; import { HuddleScreen } from '@/features/huddle/HuddleScreen'; +import { NetworkSettingsScreen } from '@/features/network-settings/NetworkSettingsScreen'; import { SettingsScreen } from '@/features/settings/SettingsScreen'; import { AccountScreen } from '@/features/settings/AccountScreen'; import type { RootStackParamList } from './types'; @@ -54,6 +55,11 @@ export function RootNavigator() { component={NewStreamScreen} options={{ animation: 'slide_from_bottom' }} /> + diff --git a/js/mobile/src/navigation/types.ts b/js/mobile/src/navigation/types.ts index f79ec26..876ab0d 100644 --- a/js/mobile/src/navigation/types.ts +++ b/js/mobile/src/navigation/types.ts @@ -15,6 +15,7 @@ export type RootStackParamList = { serverUrl: string; }; NewStream: { networkId: string }; + NetworkSettings: { networkId: string }; Settings: undefined; Account: undefined; }; diff --git a/js/mobile/src/stores/auth-store.ts b/js/mobile/src/stores/auth-store.ts index 852a61a..6f1c2f4 100644 --- a/js/mobile/src/stores/auth-store.ts +++ b/js/mobile/src/stores/auth-store.ts @@ -63,6 +63,8 @@ interface AuthState { requestCode: (email: string) => Promise; signIn: (email: string, code: string) => Promise; signOut: () => Promise; + /** Re-fetch the current user from Orion (e.g. after an avatar change). */ + refreshUser: () => Promise; /** * Wipes the session in response to a server-detected auth failure (e.g. a * 401 surfaced through react-query). Does not call `/auth/sign-out`; the @@ -166,6 +168,15 @@ export const useAuthStore = create((set, get) => ({ } }, + refreshUser: async () => { + try { + const user = await apiClient.me(); + set({ user }); + } catch (err) { + logError(err, { scope: 'auth.refreshUser' }); + } + }, + invalidateSession: async () => { stopPushTokenSync(); apiClient.setToken(null); diff --git a/js/mobile/yarn.lock b/js/mobile/yarn.lock index bc433ea..9ebf56e 100644 --- a/js/mobile/yarn.lock +++ b/js/mobile/yarn.lock @@ -4278,6 +4278,18 @@ expo-haptics@~15.0.7: resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3" integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g== +expo-image-loader@~6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072" + integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ== + +expo-image-picker@~17.0.8: + version "17.0.11" + resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.11.tgz#8f1537df946f7c19747ba9d0043ef7e3e8cb67dc" + integrity sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ== + dependencies: + expo-image-loader "~6.0.0" + expo-keep-awake@~15.0.8: version "15.0.8" resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d" -- 2.54.0 From 0cc6024621d7dcd66b80dd6485fc75885984aa8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:59:43 +0000 Subject: [PATCH 03/12] =?UTF-8?q?mobile:=20task=20particles=20=E2=80=94=20?= =?UTF-8?q?view,=20edit,=20and=20compose=20(parity=20phase=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV --- js/mobile/src/api/types.ts | 22 +- .../src/features/compose/ComposeDock.tsx | 136 ++++-- .../src/features/compose/TaskComposeSheet.tsx | 102 +++++ .../stream-view/FallbackParticleView.tsx | 4 - .../src/features/stream-view/StreamView.tsx | 13 + .../features/stream-view/TaskParticleView.tsx | 400 ++++++++++++++++++ .../src/features/streams/NewStreamScreen.tsx | 1 + js/mobile/src/features/streams/StreamCard.tsx | 2 +- js/mobile/src/lib/firestore-particles.ts | 2 +- js/mobile/src/lib/upload.ts | 26 ++ 10 files changed, 659 insertions(+), 49 deletions(-) create mode 100644 js/mobile/src/features/compose/TaskComposeSheet.tsx create mode 100644 js/mobile/src/features/stream-view/TaskParticleView.tsx diff --git a/js/mobile/src/api/types.ts b/js/mobile/src/api/types.ts index ab63df4..2f11e8d 100644 --- a/js/mobile/src/api/types.ts +++ b/js/mobile/src/api/types.ts @@ -146,14 +146,21 @@ export const TextPropertiesSchema = z.object({ }); export type TextProperties = z.infer; -export const QuestPropertiesSchema = z.object({ +export const ChecklistItemSchema = z.object({ + text: z.string(), + done: z.boolean(), +}); +export type ChecklistItem = z.infer; + +export const TaskPropertiesSchema = z.object({ title: z.string(), - description: z.string(), - status: z.string().optional(), + notes: z.string().optional(), + checklist: z.array(ChecklistItemSchema).optional(), // humanId assigned_to: z.string().optional(), + done: z.boolean(), }); -export type QuestProperties = z.infer; +export type TaskProperties = z.infer; export const PaperPropertiesSchema = z.object({ title: z.string(), @@ -194,7 +201,7 @@ export interface ParticlePropertiesMap { media: MediaProperties; file: FileProperties; text: TextProperties; - quest: QuestProperties; + task: TaskProperties; paper: PaperProperties; } @@ -248,8 +255,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [ ...TombstoneFields, }), ParticleBaseSchema.extend({ - type: z.literal('quest'), - properties: QuestPropertiesSchema, + type: z.literal('task'), + properties: TaskPropertiesSchema, + reactions: ReactionsSchema, ...TombstoneFields, }), ParticleBaseSchema.extend({ diff --git a/js/mobile/src/features/compose/ComposeDock.tsx b/js/mobile/src/features/compose/ComposeDock.tsx index c706257..bf86906 100644 --- a/js/mobile/src/features/compose/ComposeDock.tsx +++ b/js/mobile/src/features/compose/ComposeDock.tsx @@ -1,6 +1,11 @@ import { useCallback, useEffect, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; -import { Mic, Type as TypeIcon, Video as VideoIcon } from 'lucide-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'; @@ -8,13 +13,18 @@ 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 { createTextParticle, uploadMediaParticle } from '@/lib/upload'; +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'; @@ -48,6 +58,12 @@ 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; submitText?: (content: string) => Promise; /** @@ -63,6 +79,7 @@ export function ComposeDock({ networkId, targetPath, silentPresence = false, + allowTask = true, submitMedia, submitText: submitTextOverride, onParticleCreated, @@ -72,6 +89,7 @@ export function ComposeDock({ const [mode, setMode] = useState('video'); const [ui, setUi] = useState({ kind: 'idle' }); const [textOpen, setTextOpen] = useState(false); + const [taskOpen, setTaskOpen] = useState(false); const [camPerm, requestCamPerm] = useCameraPermissions(); const [micPerm, requestMicPerm] = useMicrophonePermissions(); @@ -79,13 +97,13 @@ export function ComposeDock({ // 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; + const isComposing = ui.kind !== 'idle' || textOpen || taskOpen; useEffect(() => { setComposing(isComposing); return () => setComposing(false); }, [isComposing, setComposing]); - useComposingBroadcast({ ui, textOpen, silent: silentPresence }); + useComposingBroadcast({ ui, textOpen, taskOpen, silent: silentPresence }); const ensurePermissions = useCallback( async (forVideo: boolean): Promise => { @@ -187,6 +205,20 @@ export function ComposeDock({ 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'; @@ -196,27 +228,31 @@ export function ComposeDock({ - - 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' ? ( - - ) : ( - - )} - + {/* Left and right clusters flex equally so the record button stays + centered regardless of how many side controls are present. */} + + + 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' ? ( + + ) : ( + + )} + + Tap to record - 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', - )} - > - - + + {allowTask ? ( + 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', + )} + > + + + ) : null} + + 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', + )} + > + + + ) : null} @@ -277,6 +329,12 @@ export function ComposeDock({ onClose={() => setTextOpen(false)} onSubmit={submitText} /> + + setTaskOpen(false)} + onSubmit={submitTask} + /> ); } @@ -284,17 +342,23 @@ export function ComposeDock({ 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 ? 'typing' : null; + ui.kind === 'recording' + ? 'recording' + : textOpen || taskOpen + ? 'typing' + : null; useEffect(() => { if (silent || !broadcast) return; diff --git a/js/mobile/src/features/compose/TaskComposeSheet.tsx b/js/mobile/src/features/compose/TaskComposeSheet.tsx new file mode 100644 index 0000000..f222b9f --- /dev/null +++ b/js/mobile/src/features/compose/TaskComposeSheet.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { toast } from 'sonner-native'; +import { BottomSheet } from '@/components/BottomSheet'; +import { toUserMessage } from '@/lib/errors'; +import { cn } from '@/lib/utils'; + +interface TaskComposeSheetProps { + open: boolean; + onClose: () => void; + /** Create the task. Must throw on failure so the sheet keeps the draft. */ + onSubmit: (input: { title: string; notes?: string }) => Promise; +} + +/** + * Quick task creator. Mirrors desktop's task-compose-step but pared to the + * essentials — title (required) plus optional notes. Checklist and assignee + * are added inline in the task card once it exists. + */ +export function TaskComposeSheet({ + open, + onClose, + onSubmit, +}: TaskComposeSheetProps) { + const [title, setTitle] = useState(''); + const [notes, setNotes] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); + if (open) { + setTitle(''); + setNotes(''); + setSubmitting(false); + } + } + + const trimmedTitle = title.trim(); + const canSend = trimmedTitle.length > 0 && !submitting; + + const handleSubmit = async () => { + if (!canSend) return; + setSubmitting(true); + try { + await onSubmit({ + title: trimmedTitle, + notes: notes.trim() || undefined, + }); + onClose(); + } catch (err) { + toast.error(toUserMessage(err)); + setSubmitting(false); + } + }; + + return ( + + + New task + + + + + + + + {submitting ? 'Creating…' : 'Create task'} + + + + + ); +} diff --git a/js/mobile/src/features/stream-view/FallbackParticleView.tsx b/js/mobile/src/features/stream-view/FallbackParticleView.tsx index 51e6959..4ea69d1 100644 --- a/js/mobile/src/features/stream-view/FallbackParticleView.tsx +++ b/js/mobile/src/features/stream-view/FallbackParticleView.tsx @@ -3,7 +3,6 @@ import { Text, View } from 'react-native'; import { FileIcon, HelpCircle, - ScrollText, BookOpen, type LucideIcon, } from 'lucide-react-native'; @@ -12,7 +11,6 @@ import { useNetwork } from '@/hooks/use-networks'; import { resolveHumanDisplay } from '@/lib/humans'; const TYPE_META: Record = { - quest: { icon: ScrollText, label: 'Quest' }, paper: { icon: BookOpen, label: 'Paper' }, file: { icon: FileIcon, label: 'File' }, }; @@ -44,8 +42,6 @@ export function FallbackParticleView({ const Icon = meta.icon; const title = (() => { switch (particle.type) { - case 'quest': - return particle.properties.title; case 'paper': return particle.properties.title; case 'file': diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index e1e438e..d0e99d8 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -53,6 +53,7 @@ import { useStreamComposing, } from './stream-presence-context'; import { TextParticleView } from './TextParticleView'; +import { TaskParticleView } from './TaskParticleView'; import { MediaParticleView } from './MediaParticleView'; import { DeletedParticleView } from './DeletedParticleView'; import { FallbackParticleView } from './FallbackParticleView'; @@ -456,6 +457,18 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { contentFit={videoFit} /> ); + case 'task': + return ( + + ); default: return ( ; + +interface TaskParticleViewProps { + particle: TaskParticle; + networkId: string; + streamId: string; + paused: boolean; + onEnded: () => void; + onProgress: (ratio: number) => void; +} + +const DWELL_DURATION_S = 8; +const TICK_MS = 100; + +/** + * Editable task card. Mirrors desktop's task-particle-view — round done + * checkbox + title, notes, a checklist, and an assignee picker — persisting + * each edit straight to Firestore. A fixed 8s dwell auto-advances the stream; + * focusing any field suspends playback so typing isn't raced by the timer. + */ +export function TaskParticleView({ + particle, + networkId, + streamId, + paused, + onEnded, + onProgress, +}: TaskParticleViewProps) { + const network = useNetwork(networkId); + const safe = useStreamSafeArea(); + const docPath = toFirestoreDocPath( + particlePath(networkId, [streamId, particle.id]), + ); + + const { + title, + notes, + checklist = [], + assigned_to, + done, + } = particle.properties; + + // Suspend the dwell timer whenever a field is focused so typing isn't + // interrupted by an auto-advance. Mirrors desktop's `editing` suspender. + const [editing, setEditing] = useState(false); + useSuspendPlayback(editing, `task-edit-${particle.id}`); + + // --- Fixed dwell (mirrors TextParticleView's interval cadence) --- + const elapsedRef = useRef(0); + useEffect(() => { + elapsedRef.current = 0; + onProgress(0); + }, [particle.id, onProgress]); + + useEffect(() => { + if (paused) return; + const interval = setInterval(() => { + elapsedRef.current += TICK_MS / 1000; + const ratio = Math.min(elapsedRef.current / DWELL_DURATION_S, 1); + onProgress(ratio); + if (ratio >= 1) { + clearInterval(interval); + onEnded(); + } + }, TICK_MS); + return () => clearInterval(interval); + }, [paused, onEnded, onProgress, particle.id]); + + // Checklist writes replace the whole array; concurrent edits are + // last-write-wins (same tradeoff desktop documents). Keep a ref so a second + // edit composes on the latest local base before the next snapshot arrives. + const checklistRef = useRef(checklist); + useEffect(() => { + checklistRef.current = checklist; + }, [checklist]); + + const writeChecklist = useCallback( + (items: ChecklistItem[]) => { + checklistRef.current = items; + return updateParticleProperties<'task'>(docPath, { checklist: items }); + }, + [docPath], + ); + + const handleToggleDone = useCallback( + () => updateParticleProperties<'task'>(docPath, { done: !done }), + [docPath, done], + ); + + const handleToggleItem = useCallback( + (index: number) => { + const items = checklistRef.current.map((item, i) => + i === index ? { ...item, done: !item.done } : item, + ); + void writeChecklist(items); + }, + [writeChecklist], + ); + + const handleCommitItemText = useCallback( + (index: number, text: string) => { + const items = checklistRef.current.map((item, i) => + i === index ? { ...item, text } : item, + ); + void writeChecklist(items); + }, + [writeChecklist], + ); + + const handleRemoveItem = useCallback( + (index: number) => { + void writeChecklist(checklistRef.current.filter((_, i) => i !== index)); + }, + [writeChecklist], + ); + + const handleAddItem = useCallback( + (text: string) => { + void writeChecklist([...checklistRef.current, { text, done: false }]); + }, + [writeChecklist], + ); + + const handleAssign = useCallback( + (humanId: string | null) => { + if (!humanId) { + void updateParticle(docPath, 'properties.assigned_to', deleteField()); + } else { + void updateParticleProperties<'task'>(docPath, { + assigned_to: humanId, + }); + } + }, + [docPath], + ); + + const doneCount = checklist.filter((item) => item.done).length; + + return ( + + + {/* Title + done */} + + + {done ? : null} + + setEditing(true)} + onBlur={() => setEditing(false)} + onEndEditing={(e) => { + const value = e.nativeEvent.text; + if (value !== title) { + void updateParticleProperties<'task'>(docPath, { + title: value, + }); + } + }} + placeholder="Task title" + placeholderTextColor="rgba(255,255,255,0.3)" + multiline + className={cn( + 'flex-1 text-white text-2xl font-semibold', + done && 'text-white/50 line-through', + )} + /> + + + {/* Notes */} + setEditing(true)} + onBlur={() => setEditing(false)} + onEndEditing={(e) => { + const value = e.nativeEvent.text; + if (value !== (notes ?? '')) { + void updateParticleProperties<'task'>(docPath, { notes: value }); + } + }} + placeholder="Add notes…" + placeholderTextColor="rgba(255,255,255,0.3)" + multiline + className="text-white/80 text-base" + /> + + {/* Checklist */} + + {checklist.length > 0 ? ( + + {doneCount} / {checklist.length} done + + ) : null} + {checklist.map((item, index) => ( + handleToggleItem(index)} + onCommitText={(text) => handleCommitItemText(index, text)} + onRemove={() => handleRemoveItem(index)} + onFocusChange={setEditing} + /> + ))} + + + + {/* Assignee */} + + Assignee + + handleAssign(null)} + /> + {network?.humans?.map((human) => { + const display = resolveHumanDisplay(human.id, network?.humans); + return ( + handleAssign(human.id)} + avatarHumanId={human.id} + humans={network?.humans} + /> + ); + })} + + + + + ); +} + +function ChecklistItemRow({ + item, + onToggle, + onCommitText, + onRemove, + onFocusChange, +}: { + item: ChecklistItem; + onToggle: () => void; + onCommitText: (text: string) => void; + onRemove: () => void; + onFocusChange: (focused: boolean) => void; +}) { + return ( + + + {item.done ? : null} + + onFocusChange(true)} + onBlur={() => onFocusChange(false)} + onEndEditing={(e) => { + const value = e.nativeEvent.text; + if (value !== item.text) onCommitText(value); + }} + placeholder="Subtask" + placeholderTextColor="rgba(255,255,255,0.3)" + className={cn( + 'flex-1 text-white/90 text-sm', + item.done && 'text-white/40 line-through', + )} + /> + + + + + ); +} + +function AddChecklistItemRow({ + onAdd, + onFocusChange, +}: { + onAdd: (text: string) => void; + onFocusChange: (focused: boolean) => void; +}) { + const [draft, setDraft] = useState(''); + + const submit = () => { + const text = draft.trim(); + if (!text) return; + onAdd(text); + setDraft(''); + }; + + return ( + + + onFocusChange(true)} + onBlur={() => onFocusChange(false)} + onSubmitEditing={submit} + blurOnSubmit={false} + placeholder="Add subtask…" + placeholderTextColor="rgba(255,255,255,0.3)" + className="flex-1 text-white/70 text-sm" + /> + + ); +} + +function AssigneeChip({ + label, + selected, + onPress, + avatarHumanId, + humans, +}: { + label: string; + selected: boolean; + onPress: () => void; + avatarHumanId?: string; + humans?: import('@/api/types').Human[]; +}) { + return ( + + {avatarHumanId ? ( + + ) : null} + + {label} + + + ); +} diff --git a/js/mobile/src/features/streams/NewStreamScreen.tsx b/js/mobile/src/features/streams/NewStreamScreen.tsx index 9ca70f8..268ccf9 100644 --- a/js/mobile/src/features/streams/NewStreamScreen.tsx +++ b/js/mobile/src/features/streams/NewStreamScreen.tsx @@ -189,6 +189,7 @@ export function NewStreamScreen({ networkId={networkId} targetPath={placeholderPath} silentPresence + allowTask={false} submitMedia={submitMedia} submitText={submitText} /> diff --git a/js/mobile/src/features/streams/StreamCard.tsx b/js/mobile/src/features/streams/StreamCard.tsx index ce5d5cf..dcdaf2a 100644 --- a/js/mobile/src/features/streams/StreamCard.tsx +++ b/js/mobile/src/features/streams/StreamCard.tsx @@ -87,7 +87,7 @@ export const StreamCard = memo(function StreamCard({ return latestChild.properties.content; case 'file': return latestChild.properties.filename; - case 'quest': + case 'task': return latestChild.properties.title; case 'paper': return latestChild.properties.title; diff --git a/js/mobile/src/lib/firestore-particles.ts b/js/mobile/src/lib/firestore-particles.ts index a678b4c..b40754d 100644 --- a/js/mobile/src/lib/firestore-particles.ts +++ b/js/mobile/src/lib/firestore-particles.ts @@ -97,7 +97,7 @@ const particleConverter: FirestoreDataConverter = { case 'media': case 'file': case 'text': - case 'quest': + case 'task': case 'paper': { // Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text // particles carry `properties.edited_at`, so coerce it if present. diff --git a/js/mobile/src/lib/upload.ts b/js/mobile/src/lib/upload.ts index f4ea35d..06163df 100644 --- a/js/mobile/src/lib/upload.ts +++ b/js/mobile/src/lib/upload.ts @@ -103,6 +103,32 @@ export async function createTextParticle({ 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 { + 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'; -- 2.54.0 From 9a46f3912120c7f82e8f9dc5f48ed74fbb0e6410 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 02:03:40 +0000 Subject: [PATCH 04/12] 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) Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV --- js/mobile/src/components/MarkdownBody.tsx | 165 ++++++++++++++++++ .../stream-view/FallbackParticleView.tsx | 37 +--- .../features/stream-view/FileParticleView.tsx | 103 +++++++++++ .../stream-view/PaperParticleView.tsx | 79 +++++++++ .../src/features/stream-view/StreamView.tsx | 21 +++ .../features/stream-view/TextParticleView.tsx | 163 +---------------- 6 files changed, 380 insertions(+), 188 deletions(-) create mode 100644 js/mobile/src/components/MarkdownBody.tsx create mode 100644 js/mobile/src/features/stream-view/FileParticleView.tsx create mode 100644 js/mobile/src/features/stream-view/PaperParticleView.tsx diff --git a/js/mobile/src/components/MarkdownBody.tsx b/js/mobile/src/components/MarkdownBody.tsx new file mode 100644 index 0000000..ab171cb --- /dev/null +++ b/js/mobile/src/components/MarkdownBody.tsx @@ -0,0 +1,165 @@ +import { Fragment, type ReactNode } from 'react'; +import { Platform, type ViewStyle } from 'react-native'; +import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked'; + +// Shared markdown rendering for text and paper particles. Mirrors the desktop +// Crepe palette (markdown-editor.css `--crepe-*`) so a message reads the same +// on both surfaces: white-on-transparent text, a blue accent, pink inline +// code, and a near-opaque dark surface behind code blocks and tables. +// +// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe +// uses CodeMirror; react-native-marked only exposes the language tag). They +// render as plain monospace on the dark surface, which is acceptable for v1. +const TEXT_COLOR = 'rgba(255,255,255,0.92)'; +const ACCENT = '#60a5fa'; +const SURFACE = 'rgba(24,24,28,0.96)'; +const OUTLINE = 'rgba(255,255,255,0.2)'; +const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace'; + +// react-native-marked doesn't render GFM task-list checkboxes (marked strips +// the `[ ]`/`[x]` into token flags the parser ignores), so a write/read drift +// shows up as bullets with no box. Swap the marker for a checkbox glyph before +// parsing — read-only, matching desktop's bullet-free checkboxes. +const TASK_ITEM_RE = /^(\s*)[-*+] \[([ xX])\] /gm; + +function withTaskCheckboxes(markdown: string): string { + return markdown.replace( + TASK_ITEM_RE, + (_match, indent: string, mark: string) => + `${indent}${mark === ' ' ? '☐' : '☑'} `, + ); +} + +const MARKDOWN_THEME = { + colors: { + text: TEXT_COLOR, + link: ACCENT, + code: SURFACE, + border: OUTLINE, + }, +}; + +const MARKDOWN_STYLES: MarkedStyles = { + text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 }, + li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 }, + strong: { fontWeight: '700' }, + em: { fontStyle: 'italic' }, + strikethrough: { + textDecorationLine: 'line-through', + color: 'rgba(255,255,255,0.6)', + }, + // fontStyle "normal" cancels react-native-marked's italic-by-default for + // links and inline code (desktop renders neither italic). + link: { color: ACCENT, fontStyle: 'normal' }, + // borderBottomWidth 0 removes the library's default heading underline rule, + // which desktop's headings don't have. + h1: { + color: '#ffffff', + fontSize: 28, + lineHeight: 34, + fontWeight: '700', + marginTop: 8, + marginBottom: 8, + borderBottomWidth: 0, + }, + h2: { + color: '#ffffff', + fontSize: 24, + lineHeight: 30, + fontWeight: '700', + marginTop: 8, + marginBottom: 6, + borderBottomWidth: 0, + }, + h3: { + color: '#ffffff', + fontSize: 20, + lineHeight: 26, + fontWeight: '600', + marginTop: 6, + marginBottom: 4, + }, + h4: { + color: '#ffffff', + fontSize: 18, + lineHeight: 24, + fontWeight: '600', + marginTop: 6, + marginBottom: 4, + }, + h5: { + color: '#ffffff', + fontSize: 16, + lineHeight: 22, + fontWeight: '600', + marginTop: 4, + marginBottom: 2, + }, + h6: { + color: 'rgba(255,255,255,0.7)', + fontSize: 15, + lineHeight: 20, + fontWeight: '600', + marginTop: 4, + marginBottom: 2, + }, + codespan: { + color: '#fca5a5', + fontFamily: MONO, + fontStyle: 'normal', + backgroundColor: 'rgba(255,255,255,0.1)', + }, + code: { + backgroundColor: SURFACE, + borderColor: OUTLINE, + borderWidth: 1, + borderRadius: 8, + padding: 12, + marginVertical: 6, + }, + blockquote: { + borderLeftWidth: 3, + borderLeftColor: OUTLINE, + paddingLeft: 12, + marginVertical: 6, + opacity: 0.85, + }, + // hr is left to the library default, which already draws a 1px rule in the + // themed border color (OUTLINE). + table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 }, + tableRow: { borderColor: OUTLINE }, + tableCell: { borderColor: OUTLINE, padding: 8 }, +}; + +// react-native-marked feeds fenced code blocks the `em` (italic, proportional) +// text style, so out of the box code renders italic in the body font. Override +// `code` to apply a monospace, non-italic style instead — matching desktop's +// code blocks. +const CODE_TEXT_STYLE = { + color: TEXT_COLOR, + fontFamily: MONO, + fontSize: 15, + lineHeight: 22, +}; + +class MarkdownRenderer extends Renderer { + code(text: string, language?: string, containerStyle?: ViewStyle): ReactNode { + return super.code(text, language, containerStyle, CODE_TEXT_STYLE); + } +} + +const MARKDOWN_RENDERER = new MarkdownRenderer(); + +/** + * Renders GFM markdown using the shared Flowy palette. `useMarkdown` returns an + * array of block nodes; we splat them into a Fragment so they nest cleanly + * inside a parent ScrollView (vs. the library's own FlatList-based component). + */ +export function MarkdownBody({ content }: { content: string }) { + const nodes = useMarkdown(withTaskCheckboxes(content), { + renderer: MARKDOWN_RENDERER, + theme: MARKDOWN_THEME, + styles: MARKDOWN_STYLES, + }); + return {nodes}; +} diff --git a/js/mobile/src/features/stream-view/FallbackParticleView.tsx b/js/mobile/src/features/stream-view/FallbackParticleView.tsx index 4ea69d1..a5098cd 100644 --- a/js/mobile/src/features/stream-view/FallbackParticleView.tsx +++ b/js/mobile/src/features/stream-view/FallbackParticleView.tsx @@ -1,20 +1,10 @@ import { useEffect } from 'react'; import { Text, View } from 'react-native'; -import { - FileIcon, - HelpCircle, - BookOpen, - type LucideIcon, -} from 'lucide-react-native'; +import { HelpCircle } from 'lucide-react-native'; import type { Particle } from '@/api/types'; import { useNetwork } from '@/hooks/use-networks'; import { resolveHumanDisplay } from '@/lib/humans'; -const TYPE_META: Record = { - paper: { icon: BookOpen, label: 'Paper' }, - file: { icon: FileIcon, label: 'File' }, -}; - const PLACEHOLDER_DURATION_MS = 5000; interface FallbackParticleViewProps { @@ -24,6 +14,10 @@ interface FallbackParticleViewProps { onEnded: () => void; } +// Catch-all for particle types this client version doesn't render with a +// dedicated view (e.g. a folder slipping into a stream, or a future type a +// newer client wrote). Known content types — media, text, task, paper, file — +// each have their own view in StreamView's switch. export function FallbackParticleView({ particle, networkId, @@ -35,23 +29,8 @@ export function FallbackParticleView({ particle.created_by_human_id, network?.humans, ); - const meta = TYPE_META[particle.type] ?? { - icon: HelpCircle, - label: particle.type, - }; - const Icon = meta.icon; - const title = (() => { - switch (particle.type) { - case 'paper': - return particle.properties.title; - case 'file': - return particle.properties.filename; - case 'folder': - return particle.properties.name; - default: - return null; - } - })(); + const Icon = HelpCircle; + const title = particle.type === 'folder' ? particle.properties.name : null; useEffect(() => { if (paused) return; @@ -66,7 +45,7 @@ export function FallbackParticleView({ - {meta.label} + {particle.type} {title ? ( diff --git a/js/mobile/src/features/stream-view/FileParticleView.tsx b/js/mobile/src/features/stream-view/FileParticleView.tsx new file mode 100644 index 0000000..54594d6 --- /dev/null +++ b/js/mobile/src/features/stream-view/FileParticleView.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react'; +import { Linking, Pressable, Text, View } from 'react-native'; +import { Download, FileIcon } from 'lucide-react-native'; +import { toast } from 'sonner-native'; +import type { Particle } from '@/api/types'; +import { apiClient } from '@/api/client'; +import { toUserMessage } from '@/lib/errors'; + +type FileParticle = Extract; + +interface FileParticleViewProps { + particle: FileParticle; + paused: boolean; + onEnded: () => void; +} + +// Files don't auto-play; give the reader a beat to act before advancing. +const DWELL_DURATION_MS = 8000; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(0)} KB`; + const mb = kb / 1024; + if (mb < 1024) return `${mb.toFixed(1)} MB`; + return `${(mb / 1024).toFixed(1)} GB`; +} + +/** + * File particle: name, size, and a download action. Tapping resolves a signed + * URL and opens it (the OS handles the download / preview). Mirrors desktop's + * file attachment, minus in-app preview. + */ +export function FileParticleView({ + particle, + paused, + onEnded, +}: FileParticleViewProps) { + const { filename, size_bytes, object_id } = particle.properties; + const [downloading, setDownloading] = useState(false); + const elapsedRef = useRef(0); + + // Pause the dwell while a download is being resolved so the stream doesn't + // advance out from under the user mid-tap. + useEffect(() => { + if (paused || downloading) return; + const start = Date.now(); + const interval = setInterval(() => { + elapsedRef.current += Date.now() - start; + if (elapsedRef.current >= DWELL_DURATION_MS) { + clearInterval(interval); + onEnded(); + } + }, 250); + return () => clearInterval(interval); + }, [paused, downloading, onEnded, particle.id]); + + const handleDownload = async () => { + setDownloading(true); + try { + const url = await apiClient.getParticleDownloadUrl(object_id); + const canOpen = await Linking.canOpenURL(url); + if (!canOpen) throw new Error('Could not open this file.'); + await Linking.openURL(url); + } catch (err) { + toast.error(toUserMessage(err)); + } finally { + setDownloading(false); + } + }; + + return ( + + + + + + + {filename} + + + {formatBytes(size_bytes)} + + + + + + + + {downloading ? 'Opening…' : 'Download'} + + + + + ); +} diff --git a/js/mobile/src/features/stream-view/PaperParticleView.tsx b/js/mobile/src/features/stream-view/PaperParticleView.tsx new file mode 100644 index 0000000..1d005a6 --- /dev/null +++ b/js/mobile/src/features/stream-view/PaperParticleView.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef } from 'react'; +import { ScrollView, Text, View } from 'react-native'; +import type { Particle } from '@/api/types'; +import { MarkdownBody } from '@/components/MarkdownBody'; +import { useStreamSafeArea } from './stream-safe-area'; + +type PaperParticle = Extract; + +interface PaperParticleViewProps { + particle: PaperParticle; + paused: boolean; + onEnded: () => void; + onProgress: (ratio: number) => void; +} + +// Papers are longer-form documents, so they read at the text cadence but with a +// higher cap — the reader can still scroll at their own pace while the timer +// ticks toward auto-advance. +const CHARS_PER_MINUTE = 1000; +const MIN_DURATION_S = 4; +const MAX_DURATION_S = 30; +const TICK_MS = 100; + +function computeReadDuration(text: string): number { + const base = (text.length / CHARS_PER_MINUTE) * 60; + return Math.min(Math.max(base, MIN_DURATION_S), MAX_DURATION_S); +} + +/** + * Read-only paper (document) view. Mirrors desktop's paper rendering: a title + * heading above markdown body, scrollable, with a length-based dwell timer. + */ +export function PaperParticleView({ + particle, + paused, + onEnded, + onProgress, +}: PaperParticleViewProps) { + const { title, content } = particle.properties; + const safe = useStreamSafeArea(); + const durationS = computeReadDuration(content); + const elapsedRef = useRef(0); + + useEffect(() => { + elapsedRef.current = 0; + onProgress(0); + }, [particle.id, onProgress]); + + useEffect(() => { + if (paused) return; + const interval = setInterval(() => { + elapsedRef.current += TICK_MS / 1000; + const ratio = Math.min(elapsedRef.current / durationS, 1); + onProgress(ratio); + if (ratio >= 1) { + clearInterval(interval); + onEnded(); + } + }, TICK_MS); + return () => clearInterval(interval); + }, [paused, durationS, onEnded, onProgress, particle.id]); + + return ( + + + {title} + + + + ); +} diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index d0e99d8..77aec18 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -54,6 +54,8 @@ import { } from './stream-presence-context'; import { TextParticleView } from './TextParticleView'; import { TaskParticleView } from './TaskParticleView'; +import { PaperParticleView } from './PaperParticleView'; +import { FileParticleView } from './FileParticleView'; import { MediaParticleView } from './MediaParticleView'; import { DeletedParticleView } from './DeletedParticleView'; import { FallbackParticleView } from './FallbackParticleView'; @@ -469,6 +471,25 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { onProgress={setProgress} /> ); + case 'paper': + return ( + + ); + case 'file': + return ( + + ); default: return ( - `${indent}${mark === ' ' ? '☐' : '☑'} `, - ); -} - -// Mirror the desktop Crepe palette (markdown-editor.css `--crepe-*`) so a -// message reads the same on both surfaces: white-on-transparent text, a blue -// accent, pink inline code, and a near-opaque dark surface behind code blocks -// and tables. Defined at module scope so the references stay stable — -// `useMarkdown` re-parses only when these or the content change. -// -// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe -// uses CodeMirror; react-native-marked only exposes the language tag). They -// render as plain monospace on the dark surface, which is acceptable for v1. -const TEXT_COLOR = 'rgba(255,255,255,0.92)'; -const ACCENT = '#60a5fa'; -const SURFACE = 'rgba(24,24,28,0.96)'; -const OUTLINE = 'rgba(255,255,255,0.2)'; -const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace'; - -const MARKDOWN_THEME = { - colors: { - text: TEXT_COLOR, - link: ACCENT, - code: SURFACE, - border: OUTLINE, - }, -}; - -const MARKDOWN_STYLES: MarkedStyles = { - text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 }, - li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 }, - strong: { fontWeight: '700' }, - em: { fontStyle: 'italic' }, - strikethrough: { - textDecorationLine: 'line-through', - color: 'rgba(255,255,255,0.6)', - }, - // fontStyle "normal" cancels react-native-marked's italic-by-default for - // links and inline code (desktop renders neither italic). - link: { color: ACCENT, fontStyle: 'normal' }, - // borderBottomWidth 0 removes the library's default heading underline rule, - // which desktop's headings don't have. - h1: { - color: '#ffffff', - fontSize: 28, - lineHeight: 34, - fontWeight: '700', - marginTop: 8, - marginBottom: 8, - borderBottomWidth: 0, - }, - h2: { - color: '#ffffff', - fontSize: 24, - lineHeight: 30, - fontWeight: '700', - marginTop: 8, - marginBottom: 6, - borderBottomWidth: 0, - }, - h3: { - color: '#ffffff', - fontSize: 20, - lineHeight: 26, - fontWeight: '600', - marginTop: 6, - marginBottom: 4, - }, - h4: { - color: '#ffffff', - fontSize: 18, - lineHeight: 24, - fontWeight: '600', - marginTop: 6, - marginBottom: 4, - }, - h5: { - color: '#ffffff', - fontSize: 16, - lineHeight: 22, - fontWeight: '600', - marginTop: 4, - marginBottom: 2, - }, - h6: { - color: 'rgba(255,255,255,0.7)', - fontSize: 15, - lineHeight: 20, - fontWeight: '600', - marginTop: 4, - marginBottom: 2, - }, - codespan: { - color: '#fca5a5', - fontFamily: MONO, - fontStyle: 'normal', - backgroundColor: 'rgba(255,255,255,0.1)', - }, - code: { - backgroundColor: SURFACE, - borderColor: OUTLINE, - borderWidth: 1, - borderRadius: 8, - padding: 12, - marginVertical: 6, - }, - blockquote: { - borderLeftWidth: 3, - borderLeftColor: OUTLINE, - paddingLeft: 12, - marginVertical: 6, - opacity: 0.85, - }, - // hr is left to the library default, which already draws a 1px rule in the - // themed border color (OUTLINE). - table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 }, - tableRow: { borderColor: OUTLINE }, - tableCell: { borderColor: OUTLINE, padding: 8 }, -}; - -// react-native-marked feeds fenced code blocks the `em` (italic, proportional) -// text style, so out of the box code renders italic in the body font. Override -// `code` to apply a monospace, non-italic style instead — matching desktop's -// code blocks. Instantiated once at module scope to keep the reference stable -// for `useMarkdown`'s memoization. -const CODE_TEXT_STYLE = { - color: TEXT_COLOR, - fontFamily: MONO, - fontSize: 15, - lineHeight: 22, -}; - -class MarkdownRenderer extends Renderer { - code(text: string, language?: string, containerStyle?: ViewStyle): ReactNode { - return super.code(text, language, containerStyle, CODE_TEXT_STYLE); - } -} - -const MARKDOWN_RENDERER = new MarkdownRenderer(); - export function TextParticleView({ particle, paused, @@ -208,11 +58,6 @@ export function TextParticleView({ const durationS = computeReadDuration(content); const elapsedRef = useRef(0); const safe = useStreamSafeArea(); - const markdownNodes = useMarkdown(withTaskCheckboxes(content), { - renderer: MARKDOWN_RENDERER, - theme: MARKDOWN_THEME, - styles: MARKDOWN_STYLES, - }); const editedLabel = editedAt ? ( @@ -290,7 +135,7 @@ export function TextParticleView({ showsVerticalScrollIndicator indicatorStyle="white" > - {markdownNodes} + {editedLabel} -- 2.54.0 From c161d96534cabc09a79ae58c6608bb8e45559660 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 02:05:12 +0000 Subject: [PATCH 05/12] mobile: billing & usage in network settings (parity phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV --- .../network-settings/BillingSection.tsx | 281 ++++++++++++++++++ .../NetworkSettingsScreen.tsx | 5 + js/mobile/src/hooks/use-billing.ts | 33 ++ 3 files changed, 319 insertions(+) create mode 100644 js/mobile/src/features/network-settings/BillingSection.tsx create mode 100644 js/mobile/src/hooks/use-billing.ts diff --git a/js/mobile/src/features/network-settings/BillingSection.tsx b/js/mobile/src/features/network-settings/BillingSection.tsx new file mode 100644 index 0000000..8d8dbb1 --- /dev/null +++ b/js/mobile/src/features/network-settings/BillingSection.tsx @@ -0,0 +1,281 @@ +import { useState } from 'react'; +import { Linking, Pressable, Text, View } from 'react-native'; +import { ExternalLink } from 'lucide-react-native'; +import { toast } from 'sonner-native'; +import type { BillingCadence, BillingStatus } from '@/api/types'; +import { + useCreateCheckoutSession, + useCreatePortalSession, + useNetworkBilling, + useNetworkUsage, +} from '@/hooks/use-billing'; +import { useIsNetworkAdmin } from '@/hooks/use-networks'; +import { toUserMessage } from '@/lib/errors'; +import { cn } from '@/lib/utils'; + +function formatCents(cents: number): string { + if (cents % 100 === 0) return `$${cents / 100}`; + return `$${(cents / 100).toFixed(2)}`; +} + +function formatDate(date: Date): string { + return date.toLocaleDateString(undefined, { + month: 'long', + day: 'numeric', + year: 'numeric', + }); +} + +function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + {label} + + {value} + + ); +} + +/** + * Plan + usage summary for every member, plus admin-only upgrade/manage + * controls. Mirrors desktop's BillingSection — `/usage` powers the + * everyone-visible summary; `/billing` (admin-gated) drives the controls. + * Stripe checkout/portal URLs are opened in the system browser. + */ +export function BillingSection({ networkId }: { networkId: string }) { + const isAdmin = useIsNetworkAdmin(networkId); + const { data: usage } = useNetworkUsage(networkId); + + const isPro = usage?.plan === 'pro'; + + return ( + + + Plan & billing + + + + {isPro ? 'Llink Pro' : 'Llink Free'} + + } + /> + {!isPro && usage?.limit != null ? ( + + {usage.used} / {usage.limit} + + } + /> + ) : null} + + {isAdmin ? : null} + + ); +} + +function AdminBillingControls({ networkId }: { networkId: string }) { + const { + data: billing, + isLoading, + error, + } = useNetworkBilling(networkId, true); + + if (isLoading || !billing) { + return ( + + {error ? `Couldn’t load billing: ${toUserMessage(error)}` : 'Loading…'} + + ); + } + + return billing.plan === 'pro' ? ( + + ) : ( + + ); +} + +function FreeBilling({ + networkId, + billing, +}: { + networkId: string; + billing: BillingStatus; +}) { + const createCheckout = useCreateCheckoutSession(networkId); + const [cadence, setCadence] = useState('annual'); + + const annualPerSeatMonthlyCents = Math.round(billing.price_annual_cents / 12); + const savingsPct = Math.round( + (1 - annualPerSeatMonthlyCents / billing.price_monthly_cents) * 100, + ); + + const handleUpgrade = async () => { + try { + const { url } = await createCheckout.mutateAsync(cadence); + await Linking.openURL(url); + } catch (err) { + toast.error(toUserMessage(err)); + } + }; + + return ( + + 0 ? `Save ${savingsPct}%` : undefined} + selected={cadence === 'annual'} + onPress={() => setCadence('annual')} + /> + setCadence('monthly')} + /> + + + {createCheckout.isPending ? 'Opening Stripe…' : 'Upgrade to Pro'} + + + + ); +} + +function CadenceOption({ + label, + note, + perSeatCents, + badge, + selected, + onPress, +}: { + label: string; + note: string; + perSeatCents: number; + badge?: string; + selected: boolean; + onPress: () => void; +}) { + return ( + + + + + {label} + {badge ? ( + + + {badge} + + + ) : null} + + {note} + + + + {formatCents(perSeatCents)} + + per seat / mo + + + ); +} + +function ProBilling({ + networkId, + billing, +}: { + networkId: string; + billing: BillingStatus; +}) { + const createPortal = useCreatePortalSession(networkId); + + const cadenceLabel = billing.cadence === 'annual' ? 'Annual' : 'Monthly'; + const perSeatCents = + billing.cadence === 'annual' + ? Math.round(billing.price_annual_cents / 12) + : billing.price_monthly_cents; + const renewal = billing.current_period_end + ? formatDate(billing.current_period_end) + : null; + + const handleManage = async () => { + try { + const { url } = await createPortal.mutateAsync(); + await Linking.openURL(url); + } catch (err) { + toast.error(toUserMessage(err)); + } + }; + + return ( + + {billing.cancel_at_period_end && renewal ? ( + + Your subscription downgrades to Free on {renewal}. + + ) : null} + {billing.plan_status === 'past_due' ? ( + + Your last payment failed. Update your payment method to keep Pro + active. + + ) : null} + + + {`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`} + + } + /> + {billing.seats}} + /> + {renewal ? ( + {renewal}} + /> + ) : null} + + + + + {createPortal.isPending ? 'Opening Stripe…' : 'Manage subscription'} + + + + ); +} diff --git a/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx b/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx index 0331a67..9ae3632 100644 --- a/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx +++ b/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx @@ -15,6 +15,7 @@ import { Avatar } from '@/components/Avatar'; import { toUserMessage } from '@/lib/errors'; import type { RootStackScreenProps } from '@/navigation/types'; import { AddMembersSheet } from './AddMembersSheet'; +import { BillingSection } from './BillingSection'; export function NetworkSettingsScreen({ route, @@ -157,6 +158,10 @@ export function NetworkSettingsScreen({ )} ) : null} + + + + {isAdmin ? ( diff --git a/js/mobile/src/hooks/use-billing.ts b/js/mobile/src/hooks/use-billing.ts new file mode 100644 index 0000000..2163efa --- /dev/null +++ b/js/mobile/src/hooks/use-billing.ts @@ -0,0 +1,33 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { BillingCadence } from '@/api/types'; + +/** Plan + quota summary. Member-accessible (sourced from `/usage`). */ +export function useNetworkUsage(networkId: string) { + return useQuery({ + queryKey: ['network-usage', networkId], + queryFn: () => apiClient.getNetworkUsage(networkId), + }); +} + +/** Full billing status. Admin-gated (`/billing`). */ +export function useNetworkBilling(networkId: string, enabled: boolean) { + return useQuery({ + queryKey: ['network-billing', networkId], + queryFn: () => apiClient.getNetworkBilling(networkId), + enabled, + }); +} + +export function useCreateCheckoutSession(networkId: string) { + return useMutation({ + mutationFn: (cadence: BillingCadence) => + apiClient.createCheckoutSession(networkId, cadence), + }); +} + +export function useCreatePortalSession(networkId: string) { + return useMutation({ + mutationFn: () => apiClient.createPortalSession(networkId), + }); +} -- 2.54.0 From c6ce1bdc65899d36ba72444f61cb96fa9bb8ec27 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sun, 21 Jun 2026 08:32:58 -0700 Subject: [PATCH 06/12] fix(desktop): keep `esc` inside compose instead of exiting the stream (#294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While recording or reviewing a compose, Escape both cancelled the compose and exited the stream. The stream's window-level navigation handler skips keys when an input is focused (isTypingTarget), which is why the text path was unaffected, but recording/reviewing have no focused input so Escape leaked through to the exit handler. Fix it at the compose layer rather than teaching the navigation pipe about compose: the compose key handler now consumes (preventDefault + stopPropagation) any key it handles and listens in the capture phase, so it reliably wins over the stream's bubble-phase navigation/action handlers regardless of listener registration order. Pure pause states (hold-space) are untouched, so Escape still exits the stream there. This leaves the typing/task/configuring paths equivalent (their onCancel is the same cancel() the handler invokes) while ⌘+Enter / ⌘+M still fall through to the text editor. Claude-Session: https://claude.ai/code/session_01DapjFX1MhYPZeJv4s56K5L Co-authored-by: Claude --- .../src/features/compose/compose-overlay.tsx | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/js/desktop/src/features/compose/compose-overlay.tsx b/js/desktop/src/features/compose/compose-overlay.tsx index 9144fa2..c4a0bdd 100644 --- a/js/desktop/src/features/compose/compose-overlay.tsx +++ b/js/desktop/src/features/compose/compose-overlay.tsx @@ -595,6 +595,16 @@ export function ComposeOverlay({ const handleKeyDown = (e: KeyboardEvent) => { const currentStep = stepRef.current; + // Consume a key compose handles so it never reaches the stream's + // window-level navigation/action handlers. Without this, e.g. Escape + // while recording or reviewing would both cancel compose and exit the + // stream. Listening in the capture phase (see below) guarantees compose + // sees the key before those handlers regardless of registration order. + const consume = () => { + e.preventDefault(); + e.stopPropagation(); + }; + if ( currentStep === 'typing' || currentStep === 'task' || @@ -602,7 +612,7 @@ export function ComposeOverlay({ currentStep === 'picking' ) { if (e.key === 'Escape') { - e.preventDefault(); + consume(); cancel(); } return; @@ -620,19 +630,19 @@ export function ComposeOverlay({ switch (currentStep) { case 'idle': { if (e.key === '`' && !e.repeat) { - e.preventDefault(); + consume(); handleRecordIntent(); } else if (e.key === 's' || e.key === 'S') { - e.preventDefault(); + consume(); if (!guardIdle()) break; if (!requireDesktop('Screen recording')) break; setRecordingSource('screen'); setStepSync('picking'); } else if (e.key === 't' || e.key === 'T') { - e.preventDefault(); + consume(); handleTextIntent(); } else if (e.key === 'd' || e.key === 'D') { - e.preventDefault(); + consume(); handleTaskIntent(); } break; @@ -641,17 +651,17 @@ export function ComposeOverlay({ case 'recording': { if (e.key === '`' && !e.repeat) { // Second tap stops media recording (toggle mode) - e.preventDefault(); + consume(); handleStopIntent(); } else if ( (e.key === 's' || e.key === 'S') && recordingSourceRef.current === 'screen' ) { // S stops screen recording when main window is focused - e.preventDefault(); + consume(); handleStopIntent(); } else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') { - e.preventDefault(); + consume(); handleCancelIntent(); } break; @@ -659,10 +669,10 @@ export function ComposeOverlay({ case 'reviewing': { if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') { - e.preventDefault(); + consume(); handleCancelIntent(); } else if (e.key === 'Enter') { - e.preventDefault(); + consume(); handleSendIntent(); } break; @@ -689,10 +699,13 @@ export function ComposeOverlay({ } }; - window.addEventListener('keydown', handleKeyDown); + // Capture phase so compose can consume keys before the stream's + // window-level navigation/action handlers (which listen in the bubble + // phase) see them. + window.addEventListener('keydown', handleKeyDown, true); window.addEventListener('keyup', handleKeyUp); return () => { - window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keydown', handleKeyDown, true); window.removeEventListener('keyup', handleKeyUp); }; }, [ -- 2.54.0 From 661577ecd2a6262c58445b37d63f9f93e52c0983 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sun, 21 Jun 2026 08:33:14 -0700 Subject: [PATCH 07/12] fix: remove enforced recording limit (#297) --- .../features/compose/recording-overlay.tsx | 37 +++++++------------ js/desktop/src/lib/constants.ts | 5 +-- 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/js/desktop/src/features/compose/recording-overlay.tsx b/js/desktop/src/features/compose/recording-overlay.tsx index 4527241..bdb1644 100644 --- a/js/desktop/src/features/compose/recording-overlay.tsx +++ b/js/desktop/src/features/compose/recording-overlay.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; import { Paperclip } from 'lucide-react'; import type { RecordingMode } from '@/stores/media-settings-store'; import { CenteredWaveform } from '@/components/audio/centered-waveform'; @@ -7,10 +8,7 @@ import { useObjectUrl } from '@/hooks/use-object-url'; import { AttachmentStrip } from '@/features/compose/attachment-strip'; import type { PendingAttachment } from '@/features/compose/attachment-strip'; import { cn } from '@/lib/utils'; -import { - RECORDING_MAX_DURATION_SECONDS, - RECORDING_WARNING_SECONDS, -} from '@/lib/constants'; +import { RECORDING_MAX_DURATION_SECONDS } from '@/lib/constants'; import { Button } from '@/components/ui/button'; import { KeyHint } from '@/components/key-hint'; import { useComposeIntentStore } from '@/stores/compose-intent-store'; @@ -38,41 +36,32 @@ interface RecordingOverlayProps { objectFit?: 'cover' | 'contain'; } -const WARNING_AT_SECONDS = - RECORDING_MAX_DURATION_SECONDS - RECORDING_WARNING_SECONDS; - -/** - * Tracks elapsed recording time and drives the time-limit UI. Keeps the - * recorder itself unaware of limits: when the cap is reached it dispatches the - * standard `stop` intent (the same path as releasing the ` key), which finishes - * the recording into the review step. - */ function useRecordingCountdown(active: boolean) { const [elapsed, setElapsed] = useState(0); - const requestIntent = useComposeIntentStore((s) => s.request); useEffect(() => { if (!active) return; + let toastSent = false; const start = Date.now(); - let stopped = false; - // Tick faster than 1s so the auto-stop lands within ~250ms of the cap, but - // only re-render when the whole-second value actually changes. const interval = setInterval(() => { const seconds = Math.floor((Date.now() - start) / 1000); - setElapsed((prev) => (prev === seconds ? prev : seconds)); - if (seconds >= RECORDING_MAX_DURATION_SECONDS && !stopped) { - stopped = true; - requestIntent('stop'); + setElapsed(seconds); + + if (seconds >= RECORDING_MAX_DURATION_SECONDS && !toastSent) { + toast.warning( + 'That is a long recording, cancel and re-record with your distilled thoughts', + ); + toastSent = true; } - }, 250); + }, 1000); return () => { clearInterval(interval); setElapsed(0); }; - }, [active, requestIntent]); + }, [active]); - return { elapsed, isWarning: elapsed >= WARNING_AT_SECONDS }; + return { elapsed, isWarning: elapsed >= RECORDING_MAX_DURATION_SECONDS }; } function RecordingTimer({ diff --git a/js/desktop/src/lib/constants.ts b/js/desktop/src/lib/constants.ts index e888941..8df2a15 100644 --- a/js/desktop/src/lib/constants.ts +++ b/js/desktop/src/lib/constants.ts @@ -4,12 +4,9 @@ export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024; /** Maximum number of file attachments per particle. */ export const MAX_ATTACHMENTS = 10; -/** Maximum duration for a media (audio/video) recording, in seconds. */ +/** Maximum recommended duration for a media (audio/video) recording, in seconds. */ export const RECORDING_MAX_DURATION_SECONDS = 60; -/** When this many seconds or fewer remain, show the red warning state. */ -export const RECORDING_WARNING_SECONDS = 10; - export const SUPPORT_EMAIL = 'team@flowylabs.ai'; export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy'; -- 2.54.0 From 4a73ccad0ba82f95ec84be6ee132f1688346f3ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 16:35:33 +0000 Subject: [PATCH 08/12] mobile: let users dismiss the keyboard from a task card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV --- .../features/stream-view/TaskParticleView.tsx | 229 ++++++++++-------- 1 file changed, 132 insertions(+), 97 deletions(-) diff --git a/js/mobile/src/features/stream-view/TaskParticleView.tsx b/js/mobile/src/features/stream-view/TaskParticleView.tsx index c14ceb4..649e08c 100644 --- a/js/mobile/src/features/stream-view/TaskParticleView.tsx +++ b/js/mobile/src/features/stream-view/TaskParticleView.tsx @@ -1,5 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; +import { + Keyboard, + Platform, + Pressable, + ScrollView, + Text, + TextInput, + View, +} from 'react-native'; import { deleteField } from 'firebase/firestore'; import { Check, Plus, X } from 'lucide-react-native'; import type { ChecklistItem, Particle } from '@/api/types'; @@ -158,119 +166,146 @@ export function TaskParticleView({ className="flex-1 items-center justify-center px-6" style={{ paddingTop: safe.top + 16, paddingBottom: safe.bottom + 16 }} > - - {/* Title + done */} - - + + {/* Title + done */} + - {done ? : null} - + + {done ? : null} + + setEditing(true)} + onBlur={() => setEditing(false)} + onEndEditing={(e) => { + const value = e.nativeEvent.text; + if (value !== title) { + void updateParticleProperties<'task'>(docPath, { + title: value, + }); + } + }} + placeholder="Task title" + placeholderTextColor="rgba(255,255,255,0.3)" + multiline + className={cn( + 'flex-1 text-white text-2xl font-semibold', + done && 'text-white/50 line-through', + )} + /> + + + {/* Notes */} setEditing(true)} onBlur={() => setEditing(false)} onEndEditing={(e) => { const value = e.nativeEvent.text; - if (value !== title) { + if (value !== (notes ?? '')) { void updateParticleProperties<'task'>(docPath, { - title: value, + notes: value, }); } }} - placeholder="Task title" + placeholder="Add notes…" placeholderTextColor="rgba(255,255,255,0.3)" multiline - className={cn( - 'flex-1 text-white text-2xl font-semibold', - done && 'text-white/50 line-through', - )} + className="text-white/80 text-base" /> - - {/* Notes */} - setEditing(true)} - onBlur={() => setEditing(false)} - onEndEditing={(e) => { - const value = e.nativeEvent.text; - if (value !== (notes ?? '')) { - void updateParticleProperties<'task'>(docPath, { notes: value }); - } - }} - placeholder="Add notes…" - placeholderTextColor="rgba(255,255,255,0.3)" - multiline - className="text-white/80 text-base" - /> - - {/* Checklist */} - - {checklist.length > 0 ? ( - - {doneCount} / {checklist.length} done - - ) : null} - {checklist.map((item, index) => ( - handleToggleItem(index)} - onCommitText={(text) => handleCommitItemText(index, text)} - onRemove={() => handleRemoveItem(index)} + {/* Checklist */} + + {checklist.length > 0 ? ( + + {doneCount} / {checklist.length} done + + ) : null} + {checklist.map((item, index) => ( + handleToggleItem(index)} + onCommitText={(text) => handleCommitItemText(index, text)} + onRemove={() => handleRemoveItem(index)} + onFocusChange={setEditing} + /> + ))} + - ))} - - + - {/* Assignee */} - - Assignee - - handleAssign(null)} - /> - {network?.humans?.map((human) => { - const display = resolveHumanDisplay(human.id, network?.humans); - return ( - handleAssign(human.id)} - avatarHumanId={human.id} - humans={network?.humans} - /> - ); - })} - - - + {/* Assignee */} + + Assignee + + handleAssign(null)} + /> + {network?.humans?.map((human) => { + const display = resolveHumanDisplay(human.id, network?.humans); + return ( + handleAssign(human.id)} + avatarHumanId={human.id} + humans={network?.humans} + /> + ); + })} + + + + + {/* While a field is focused the keyboard hides the stream's tap-zones, + so offer an explicit way out. Dismissing blurs the active field, + which flips `editing` off and resumes the dwell + tap navigation. */} + {editing ? ( + + Keyboard.dismiss()} + hitSlop={8} + accessibilityLabel="Done editing" + className="rounded-full bg-white/15 px-3 py-1.5" + > + Done + + + ) : null} + ); } -- 2.54.0 From a2a7fd4812a0f2d68a1140a06261976a58d3be19 Mon Sep 17 00:00:00 2001 From: talksik Date: Sun, 21 Jun 2026 10:06:38 -0700 Subject: [PATCH 09/12] decrease clutter in stream-view --- go/cmd/orion/main.go | 4 +- .../stream-view/StreamStatusPills.tsx | 50 +++++++++++++++++++ .../src/features/stream-view/StreamView.tsx | 39 ++++++--------- 3 files changed, 67 insertions(+), 26 deletions(-) create mode 100644 js/mobile/src/features/stream-view/StreamStatusPills.tsx diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 5587ac5..2b95a22 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -20,8 +20,8 @@ import ( "github.com/flowy-live/llink/internal" "github.com/flowy-live/llink/internal/auth" "github.com/flowy-live/llink/internal/billing" - "github.com/flowy-live/llink/internal/db" - "github.com/flowy-live/llink/internal/depot" + "github.com/flowy-live/llink/internal/db" + "github.com/flowy-live/llink/internal/depot" "github.com/flowy-live/llink/internal/handler" "github.com/flowy-live/llink/internal/human" "github.com/flowy-live/llink/internal/human/pushnotify" diff --git a/js/mobile/src/features/stream-view/StreamStatusPills.tsx b/js/mobile/src/features/stream-view/StreamStatusPills.tsx new file mode 100644 index 0000000..4ce1e55 --- /dev/null +++ b/js/mobile/src/features/stream-view/StreamStatusPills.tsx @@ -0,0 +1,50 @@ +import { Text, View } from 'react-native'; +import { Clock, Pause } from 'lucide-react-native'; + +interface StreamStatusPillsProps { + paused: boolean; + /** Null when no auto-exit is pending; ms remaining otherwise. */ + exitRemainingMs: number | null; +} + +/** + * Compact playback-state pills shown in the right chrome margin so they never + * overlay the particle canvas. "Paused" collapses to an icon-only badge; the + * exit countdown pairs a clock icon with tabular-number seconds so the digit + * tick doesn't reflow neighbors. + */ +export function StreamStatusPills({ + paused, + exitRemainingMs, +}: StreamStatusPillsProps) { + if (!paused && exitRemainingMs === null) return null; + + return ( + + {paused ? ( + + + + ) : null} + {exitRemainingMs !== null ? ( + + + + {Math.ceil(exitRemainingMs / 1000)}s + + + ) : null} + + ); +} diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index 77aec18..6601803 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -61,6 +61,7 @@ import { DeletedParticleView } from './DeletedParticleView'; import { FallbackParticleView } from './FallbackParticleView'; import { useExitCountdown } from './use-exit-countdown'; import { StreamTopActions } from './StreamTopActions'; +import { StreamStatusPills } from './StreamStatusPills'; import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet'; import { StreamMembersSheet } from './StreamMembersSheet'; import { RenameStreamSheet } from './RenameStreamSheet'; @@ -579,30 +580,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { - {/* Top status pills: paused + exit countdown. Anchored just below - the metadata row (avatar + name ≈ 40px tall, starts at - insets.top + 32) so they share the top chrome real estate - instead of competing with captions at the bottom. */} - - {paused ? ( - - - Paused - - - ) : null} - {exitRemainingMs !== null ? ( - - - Closing in {Math.ceil(exitRemainingMs / 1000)}s - - - ) : null} - @@ -658,6 +635,20 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { + {/* Playback status pills — pinned to the right chrome margin just + below the top actions row so they live in the same band as the + chrome buttons instead of overlaying the particle canvas. */} + + + + {/* Right-edge reaction stack — mirrors desktop's ReactionBar. Vertically centered on the canvas; outside the GestureDetector so each pill tap toggles cleanly without competing with the stream advance/back -- 2.54.0 From 15a31352c21b7daaacb1f21f38ec09bf99ead9b7 Mon Sep 17 00:00:00 2001 From: talksik Date: Sun, 21 Jun 2026 10:07:33 -0700 Subject: [PATCH 10/12] format --- go/cmd/orion/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 2b95a22..4e4f21f 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -20,8 +20,8 @@ import ( "github.com/flowy-live/llink/internal" "github.com/flowy-live/llink/internal/auth" "github.com/flowy-live/llink/internal/billing" - "github.com/flowy-live/llink/internal/db" - "github.com/flowy-live/llink/internal/depot" + "github.com/flowy-live/llink/internal/db" + "github.com/flowy-live/llink/internal/depot" "github.com/flowy-live/llink/internal/handler" "github.com/flowy-live/llink/internal/human" "github.com/flowy-live/llink/internal/human/pushnotify" -- 2.54.0 From 18d48366228732af006dd7606e9bf7a75f051c38 Mon Sep 17 00:00:00 2001 From: talksik Date: Sun, 21 Jun 2026 10:08:21 -0700 Subject: [PATCH 11/12] format --- go/cmd/orion/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 4e4f21f..5587ac5 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -20,8 +20,8 @@ import ( "github.com/flowy-live/llink/internal" "github.com/flowy-live/llink/internal/auth" "github.com/flowy-live/llink/internal/billing" - "github.com/flowy-live/llink/internal/db" - "github.com/flowy-live/llink/internal/depot" + "github.com/flowy-live/llink/internal/db" + "github.com/flowy-live/llink/internal/depot" "github.com/flowy-live/llink/internal/handler" "github.com/flowy-live/llink/internal/human" "github.com/flowy-live/llink/internal/human/pushnotify" -- 2.54.0 From 9ed1c949fd446f57e49425a107b28bfbdaad34c3 Mon Sep 17 00:00:00 2001 From: talksik Date: Sun, 21 Jun 2026 10:32:44 -0700 Subject: [PATCH 12/12] consolidate avatar --- js/mobile/src/components/Avatar.tsx | 13 ++++- .../src/features/huddle/HuddleScreen.tsx | 6 +-- js/mobile/src/features/networks/Drawer.tsx | 13 +++-- .../features/networks/NetworkListScreen.tsx | 11 ++-- .../src/features/settings/AccountScreen.tsx | 34 +++++------- .../features/stream-view/ReactionStack.tsx | 9 +--- .../src/features/stream-view/StreamView.tsx | 1 - js/mobile/src/features/streams/StreamCard.tsx | 54 ++++++------------- 8 files changed, 56 insertions(+), 85 deletions(-) diff --git a/js/mobile/src/components/Avatar.tsx b/js/mobile/src/components/Avatar.tsx index d52df07..4654909 100644 --- a/js/mobile/src/components/Avatar.tsx +++ b/js/mobile/src/components/Avatar.tsx @@ -4,7 +4,7 @@ import { useAvatarUrl } from '@/hooks/use-avatar-url'; import { resolveHumanDisplay } from '@/lib/humans'; import { cn } from '@/lib/utils'; -type Size = 'xs' | 'sm' | 'md'; +type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; interface AvatarProps { humanId: string | null | undefined; @@ -14,6 +14,8 @@ interface AvatarProps { online?: boolean; /** Background ring used to separate stacked avatars from the chrome. */ stackBg?: string; + /** Initials shown when the human can't be resolved (e.g. a group stream). */ + fallbackInitials?: string; className?: string; } @@ -21,6 +23,8 @@ const sizeMap: Record = { xs: { box: 'h-6 w-6', text: 'text-[9px]', ring: 1.5 }, sm: { box: 'h-9 w-9', text: 'text-xs', ring: 2 }, md: { box: 'h-10 w-10', text: 'text-sm', ring: 2 }, + lg: { box: 'h-16 w-16', text: 'text-xl', ring: 2.5 }, + xl: { box: 'h-24 w-24', text: 'text-3xl', ring: 3 }, }; /** @@ -35,12 +39,17 @@ export function Avatar({ size = 'sm', online = false, stackBg, + fallbackInitials, className, }: AvatarProps) { - const { initials } = resolveHumanDisplay(humanId, humans); + const display = resolveHumanDisplay(humanId, humans); const human = humanId ? humans?.find((h) => h.id === humanId) : undefined; const avatarUrl = useAvatarUrl(human?.avatar_object_id); const dims = sizeMap[size]; + const initials = + display.exists || fallbackInitials === undefined + ? display.initials + : fallbackInitials; return ( ) : ( - - {initials} - + )} diff --git a/js/mobile/src/features/networks/Drawer.tsx b/js/mobile/src/features/networks/Drawer.tsx index 91b686f..6a7258b 100644 --- a/js/mobile/src/features/networks/Drawer.tsx +++ b/js/mobile/src/features/networks/Drawer.tsx @@ -13,6 +13,7 @@ import { SafeAreaProvider, SafeAreaView, } from 'react-native-safe-area-context'; +import { Avatar } from '@/components/Avatar'; import { useAuthStore } from '@/stores/auth-store'; const SCREEN_WIDTH = Dimensions.get('window').width; @@ -58,8 +59,6 @@ export function Drawer({ const signOut = useAuthStore((s) => s.signOut); const isSigningOut = useAuthStore((s) => s.isSigningOut); - const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??'; - return ( - - - {initials} - - + s.user); - const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??'; // Local refreshing state — driving RefreshControl from react-query's // isRefetching can leave the native spinner visually stuck after the @@ -52,11 +52,12 @@ export function NetworkListScreen({ setDrawerOpen(true)} accessibilityLabel="Open menu" - className="bg-muted h-9 w-9 items-center justify-center rounded-full" > - - {initials} - + ) { const user = useAuthStore((s) => s.user); const refreshUser = useAuthStore((s) => s.refreshUser); - const avatarUrl = useAvatarUrl(user?.avatar_object_id); - const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? '?'; const [busy, setBusy] = useState(false); const pickAndUpload = async () => { @@ -92,17 +83,18 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) { accessibilityLabel="Change profile picture" className="relative" > - - {busy ? ( + {busy ? ( + - ) : avatarUrl ? ( - - ) : ( - - {initials} - - )} - + + ) : ( + + )} diff --git a/js/mobile/src/features/stream-view/ReactionStack.tsx b/js/mobile/src/features/stream-view/ReactionStack.tsx index c88afb0..63db52a 100644 --- a/js/mobile/src/features/stream-view/ReactionStack.tsx +++ b/js/mobile/src/features/stream-view/ReactionStack.tsx @@ -3,7 +3,7 @@ import { Pressable, Text, View } from 'react-native'; import { Plus } from 'lucide-react-native'; import * as Haptics from 'expo-haptics'; import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types'; -import { resolveHumanDisplay } from '@/lib/humans'; +import { Avatar } from '@/components/Avatar'; import { cn } from '@/lib/utils'; const EMOJI_SET = new Set(REACTION_EMOJIS); @@ -77,7 +77,6 @@ export function ReactionStack({ {activeTextKeys.map((text) => { const reactors = reactions?.[text] ?? []; const isMine = reactors.includes(currentHumanId); - const firstReactor = resolveHumanDisplay(reactors[0], humans); return ( - - - {firstReactor.initials} - - + {text} diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index 6601803..18609fc 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -579,7 +579,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { /> - diff --git a/js/mobile/src/features/streams/StreamCard.tsx b/js/mobile/src/features/streams/StreamCard.tsx index dcdaf2a..73bae88 100644 --- a/js/mobile/src/features/streams/StreamCard.tsx +++ b/js/mobile/src/features/streams/StreamCard.tsx @@ -3,11 +3,12 @@ import { Pressable, Text, View } from 'react-native'; import { Headphones } from 'lucide-react-native'; import type { Particle, StreamProperties } from '@/api/types'; import { isParticleDeleted } from '@/api/types'; +import { Avatar } from '@/components/Avatar'; import { RelativeTimestamp } from '@/components/RelativeTimestamp'; import { useLiveLatestChild } from '@/hooks/use-particle'; import { useNetwork } from '@/hooks/use-networks'; import { particlePath } from '@/lib/particle-path'; -import { cn, getInitials } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import { useAuthStore } from '@/stores/auth-store'; interface StreamCardProps { @@ -35,34 +36,20 @@ export const StreamCard = memo(function StreamCard({ particle.visible_to.length === 2 && particle.visible_to.every((v) => v.startsWith('human:')); - const initials = useMemo(() => { + // The human represented by the card: the other party in a DM, otherwise the + // author of the latest message. Group streams with no messages resolve to + // null and fall back to the stream-name initials below. + const avatarHumanId = useMemo(() => { if (isDM) { const otherEntry = particle.visible_to.find( (v) => v !== `human:${userId}`, ); - if (otherEntry) { - const otherId = otherEntry.replace('human:', ''); - const otherHuman = network?.humans?.find((h) => h.id === otherId); - if (otherHuman) return getInitials(otherHuman.email); - } + if (otherEntry) return otherEntry.replace('human:', ''); } + return latestChild?.created_by_human_id ?? null; + }, [isDM, particle.visible_to, userId, latestChild]); - if (latestChild) { - const creator = network?.humans?.find( - (h) => h.id === latestChild.created_by_human_id, - ); - if (creator) return getInitials(creator.email); - } - - return particle.properties.name.slice(0, 2).toUpperCase(); - }, [ - isDM, - particle.visible_to, - particle.properties.name, - userId, - latestChild, - network, - ]); + const fallbackInitials = particle.properties.name.slice(0, 2).toUpperCase(); const isUnseen = useMemo(() => { if (!latestChild) return false; @@ -102,21 +89,12 @@ export const StreamCard = memo(function StreamCard({ android_ripple={{ color: 'rgba(0,0,0,0.05)' }} className="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent" > - - - {initials} - - +