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