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:
@@ -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">
|
||||
You’ll 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
You’ve 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 aren’t 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user