From d49a7146e3fbb48e730f9f346c6666902a21854f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:48:09 +0000 Subject: [PATCH] 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'] }); + }, + }); +}