mobile: add invitations, network creation, and settings (parity phase 1)

Surface backend capabilities that already existed in the mobile API client
but had no UI:

- NetworkListScreen now lists pending invitations with an Accept action and
  a header "+" to create a network; empty state offers creation instead of
  pointing users to desktop.
- New CreateNetworkSheet and use-invitations hooks (accept invite, create
  network) following the existing react-query patterns.
- SettingsScreen replaces its placeholder with an email-notifications toggle
  (optimistic, mirrors desktop), app version, and sign out.
- Wire the previously-unreachable Settings row into the Drawer.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV
This commit is contained in:
Claude
2026-06-21 01:48:09 +00:00
parent bfe53b46cf
commit d49a7146e3
5 changed files with 319 additions and 15 deletions
@@ -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 (
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="50%">
<View className="px-5 pb-4">
<Text className="text-white text-lg font-semibold">New network</Text>
<Text className="text-white/50 text-sm mt-1">
Youll be the admin and can invite people next.
</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Network name"
placeholderTextColor="rgba(255,255,255,0.4)"
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreate}
editable={!createNetwork.isPending}
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4"
/>
<Pressable
onPress={handleCreate}
disabled={!canCreate}
className={cn(
'mt-4 rounded-xl py-3 items-center',
canCreate ? 'bg-white' : 'bg-white/20',
)}
>
<Text
className={cn(
'text-base font-semibold',
canCreate ? 'text-black' : 'text-white/40',
)}
>
{createNetwork.isPending ? 'Creating…' : 'Create network'}
</Text>
</Pressable>
</View>
</BottomSheet>
);
}
+13 -1
View File
@@ -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();
}}
/>
<DrawerRow
label="Settings"
onPress={() => {
onClose();
onNavigateSettings();
}}
/>
</View>
<View className="border-sidebar-border border-t px-2 py-2">
@@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
@@ -51,7 +59,13 @@ export function NetworkListScreen({
</Text>
</Pressable>
<FlowyLogo />
<View className="w-9" />
<Pressable
onPress={() => setCreateOpen(true)}
accessibilityLabel="Create network"
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
>
<Plus size={18} color="#fafafa" />
</Pressable>
</View>
{isLoading ? (
@@ -67,16 +81,21 @@ export function NetworkListScreen({
<Text className="text-foreground font-medium">Retry</Text>
</Pressable>
</View>
) : !data || data.length === 0 ? (
<EmptyState />
) : (!data || data.length === 0) && pendingInvitations.length === 0 ? (
<EmptyState onCreate={() => setCreateOpen(true)} />
) : (
<FlatList
data={data}
data={data ?? []}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
ItemSeparatorComponent={ListSeparator}
ListHeaderComponent={
pendingInvitations.length > 0 ? (
<InvitationsSection invitations={pendingInvitations} />
) : null
}
renderItem={({ item }) => (
<NetworkCard
network={item}
@@ -94,10 +113,65 @@ export function NetworkListScreen({
onNavigateAccount={() => navigation.navigate('Account')}
onNavigateSettings={() => navigation.navigate('Settings')}
/>
<CreateNetworkSheet
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={(networkId) =>
navigation.navigate('StreamList', { networkId })
}
/>
</SafeAreaView>
);
}
function InvitationsSection({ invitations }: { invitations: Invitation[] }) {
return (
<View className="border-b border-border">
<Text className="text-muted-foreground text-xs uppercase tracking-wide px-4 pt-4 pb-1">
Invitations
</Text>
{invitations.map((invitation) => (
<InvitationCard key={invitation.network_id} invitation={invitation} />
))}
</View>
);
}
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 (
<View className="flex-row items-center justify-between px-4 py-4">
<View className="flex-1 pr-3">
<Text className="text-foreground text-base font-semibold">
{invitation.network_name}
</Text>
<Text className="text-muted-foreground text-sm">
Youve been invited to join
</Text>
</View>
<Pressable
onPress={handleAccept}
disabled={acceptInvitation.isPending}
className="bg-primary rounded-full px-4 py-2"
>
<Text className="text-primary-foreground text-sm font-semibold">
{acceptInvitation.isPending ? 'Joining…' : 'Accept'}
</Text>
</Pressable>
</View>
);
}
function NetworkCard({
network,
onPress,
@@ -124,15 +198,23 @@ function NetworkCard({
);
}
function EmptyState() {
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-foreground text-lg font-medium text-center">
You arent in any networks yet.
</Text>
<Text className="text-muted-foreground mt-2 text-center">
Ask a friend for an invite, or create one on desktop.
Create one to get started, or ask a friend for an invite.
</Text>
<Pressable
onPress={onCreate}
className="bg-primary rounded-full px-5 py-3 mt-6"
>
<Text className="text-primary-foreground font-semibold">
Create a network
</Text>
</Pressable>
</View>
);
}
@@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center px-3 py-3 border-b border-border">
@@ -17,11 +54,55 @@ export function SettingsScreen({
<View className="w-8" />
</View>
<View className="flex-1 items-center justify-center px-6">
<Text className="text-muted-foreground text-center">
Theme, notifications, and account preferences land here later.
</Text>
<View className="px-4 py-4">
<SettingsGroup title="Notifications">
<View className="flex-row items-center justify-between px-4 py-3">
<Text className="text-foreground text-base">
Email notifications
</Text>
<Switch
value={emailNotifications}
onValueChange={handleToggleEmailNotifications}
/>
</View>
</SettingsGroup>
<SettingsGroup title="About">
<View className="flex-row items-center justify-between px-4 py-3">
<Text className="text-foreground text-base">Version</Text>
<Text className="text-muted-foreground text-base">{version}</Text>
</View>
</SettingsGroup>
<View className="mt-4">
<Pressable
onPress={() => void signOut()}
disabled={isSigningOut}
className="px-4 py-3 active:bg-accent rounded-xl"
>
<Text className="text-destructive text-base font-medium">
{isSigningOut ? 'Signing out…' : 'Sign out'}
</Text>
</Pressable>
</View>
</View>
</SafeAreaView>
);
}
function SettingsGroup({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="mb-2">
<Text className="text-muted-foreground text-xs uppercase tracking-wide px-4 pt-4 pb-1">
{title}
</Text>
<View className="bg-muted/40 rounded-xl overflow-hidden">{children}</View>
</View>
);
}
+39
View File
@@ -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'] });
},
});
}