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:
@@ -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">
|
||||
Couldn’t 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user