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'] });
+ },
+ });
+}