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