mobile: network member management and avatars (parity phase 2)

- 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV
This commit is contained in:
Claude
2026-06-21 01:53:31 +00:00
parent d49a7146e3
commit 6885ca355b
15 changed files with 646 additions and 12 deletions
@@ -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<string[]>([]);
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 (
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="60%">
<View className="px-5 pb-4">
<Text className="text-white text-lg font-semibold">Add members</Text>
<Text className="text-white/50 text-sm mt-1">
Existing users join right away; others get an email invite.
</Text>
{emails.length > 0 ? (
<View className="flex-row flex-wrap gap-2 mt-4">
{emails.map((email) => (
<Pressable
key={email}
onPress={() => removeEmail(email)}
className="flex-row items-center bg-white/10 rounded-full pl-3 pr-2 py-1"
>
<Text className="text-white text-sm">{email}</Text>
<Text className="text-white/50 text-base ml-1">×</Text>
</Pressable>
))}
</View>
) : null}
<TextInput
value={draft}
onChangeText={(text) => {
if (text.endsWith(',') || text.endsWith(' ')) {
setDraft(text);
commitDraft();
} else {
setDraft(text);
}
}}
placeholder="[email protected]"
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"
/>
<Pressable
onPress={handleSubmit}
disabled={!canSubmit}
className={cn(
'mt-4 rounded-xl py-3 items-center',
canSubmit ? 'bg-white' : 'bg-white/20',
)}
>
<Text
className={cn(
'text-base font-semibold',
canSubmit ? 'text-black' : 'text-white/40',
)}
>
{addMembers.isPending ? 'Sending…' : 'Send invites'}
</Text>
</Pressable>
</View>
</BottomSheet>
);
}
@@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
<Text className="text-foreground text-2xl"></Text>
</Pressable>
<Text className="flex-1 text-center text-foreground text-base font-semibold">
{network?.name ?? 'Network'}
</Text>
<View className="w-8" />
</View>
<ScrollView className="flex-1">
<View className="flex-row items-center justify-between px-4 pt-5 pb-2">
<View>
<Text className="text-foreground text-base font-semibold">
Members
</Text>
<Text className="text-muted-foreground text-xs">
{members.length} {members.length === 1 ? 'member' : 'members'}
</Text>
</View>
{isAdmin ? (
<Pressable
onPress={() => setAddOpen(true)}
className="flex-row items-center bg-primary rounded-full px-3 py-2"
>
<UserPlus size={14} color="#000000" />
<Text className="text-primary-foreground text-sm font-semibold ml-1">
Add
</Text>
</Pressable>
) : null}
</View>
<View className="px-2">
{members.map((human) => {
const isRowAdmin = human.id === network?.admin_human.id;
const canRemove =
isAdmin && !isRowAdmin && human.id !== currentUserId;
return (
<View
key={human.id}
className="flex-row items-center gap-3 px-2 py-3"
>
<Avatar humanId={human.id} humans={members} size="md" />
<View className="flex-1">
<Text className="text-foreground text-sm font-medium">
{human.email_prefix}
</Text>
<Text className="text-muted-foreground text-xs">
{human.email}
</Text>
</View>
{isRowAdmin ? (
<View className="flex-row items-center bg-muted rounded-full px-2 py-1">
<Shield size={12} color="#a6a6a6" />
<Text className="text-muted-foreground text-xs ml-1">
Admin
</Text>
</View>
) : null}
{canRemove ? (
<Pressable
onPress={() => handleRemove(human)}
hitSlop={8}
className="p-1"
accessibilityLabel={`Remove ${human.email}`}
>
<X size={16} color="#a6a6a6" />
</Pressable>
) : null}
</View>
);
})}
</View>
{isAdmin ? (
<View className="mt-4">
<Text className="text-foreground text-base font-semibold px-4 pt-2 pb-2">
Pending invitations
</Text>
{invitationsError ? (
<Text className="text-muted-foreground text-xs px-4 py-2">
Couldnt load pending invitations.
</Text>
) : pending.length === 0 ? (
<Text className="text-muted-foreground text-xs px-4 py-2">
No pending invitations.
</Text>
) : (
<View className="px-2">
{pending.map((inv) => (
<PendingInvitationRow
key={inv.email}
email={inv.email}
networkId={networkId}
/>
))}
</View>
)}
</View>
) : null}
</ScrollView>
{isAdmin ? (
<AddMembersSheet
open={addOpen}
onClose={() => setAddOpen(false)}
networkId={networkId}
/>
) : null}
</SafeAreaView>
);
}
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 (
<View className="flex-row items-center gap-3 px-2 py-3">
<View className="h-10 w-10 items-center justify-center rounded-full bg-muted">
<Mail size={16} color="#a6a6a6" />
</View>
<View className="flex-1">
<Text className="text-foreground text-sm" numberOfLines={1}>
{email}
</Text>
<Text className="text-muted-foreground text-xs">Pending</Text>
</View>
<Pressable
onPress={handleRevoke}
disabled={revokeInvitation.isPending}
hitSlop={8}
className="p-1"
accessibilityLabel={`Revoke invitation to ${email}`}
>
<X size={16} color="#a6a6a6" />
</Pressable>
</View>
);
}
@@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
@@ -18,7 +85,37 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
<View className="w-8" />
</View>
<View className="px-6 py-6 gap-4">
<View className="items-center px-6 py-8">
<Pressable
onPress={pickAndUpload}
disabled={busy}
accessibilityLabel="Change profile picture"
className="relative"
>
<View className="h-24 w-24 items-center justify-center overflow-hidden rounded-full bg-muted">
{busy ? (
<ActivityIndicator />
) : avatarUrl ? (
<Image source={{ uri: avatarUrl }} className="h-full w-full" />
) : (
<Text className="text-foreground text-3xl font-medium">
{initials}
</Text>
)}
</View>
<View className="absolute bottom-0 right-0 h-7 w-7 items-center justify-center rounded-full bg-primary border-2 border-background">
<Camera size={13} color="#000000" />
</View>
</Pressable>
{user?.avatar_object_id ? (
<Pressable onPress={removeAvatar} disabled={busy} className="mt-3">
<Text className="text-destructive text-sm">Remove photo</Text>
</Pressable>
) : null}
</View>
<View className="px-6 gap-4">
<Field label="Email" value={user?.email ?? '—'} />
</View>
</SafeAreaView>
@@ -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({
<Header
title={network?.name ?? 'Streams'}
onBack={() => 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 (
<View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable
@@ -83,7 +95,14 @@ function Header({ title, onBack }: { title: string; onBack: () => void }) {
>
{title}
</Text>
<View className="w-8" />
<Pressable
onPress={onOpenSettings}
className="px-2 py-1"
accessibilityLabel="Network settings"
hitSlop={8}
>
<SettingsIcon size={20} color="#fafafa" />
</Pressable>
</View>
);
}