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:
@@ -55,6 +55,13 @@ const config: ExpoConfig = {
|
||||
'Flowy uses your microphone to record voice messages.',
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-image-picker',
|
||||
{
|
||||
photosPermission:
|
||||
'Flowy uses your photo library to set your profile picture.',
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-notifications',
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-file-system": "~19.0.16",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image-picker": "~17.0.8",
|
||||
"expo-notifications": "~0.32.17",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy';
|
||||
import { appConfig } from '@/config/env';
|
||||
import { ApiError } from '@/lib/errors';
|
||||
import type { z } from 'zod';
|
||||
@@ -136,6 +137,45 @@ class ApiClient {
|
||||
await this.requestVoid('PATCH', '/humans/me/settings', data);
|
||||
}
|
||||
|
||||
// --- Avatar ---
|
||||
|
||||
/**
|
||||
* Upload a new profile picture. The endpoint takes the raw image bytes as
|
||||
* the request body (not multipart), so we stream the file directly via
|
||||
* expo-file-system rather than the JSON `fetch` helper.
|
||||
*/
|
||||
async uploadAvatar(fileUri: string, mimeType: string): Promise<void> {
|
||||
const headers: Record<string, string> = { 'Content-Type': mimeType };
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
const result = await uploadAsync(
|
||||
`${this.baseUrl}/humans/me/avatar`,
|
||||
fileUri,
|
||||
{
|
||||
httpMethod: 'PUT',
|
||||
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||
headers,
|
||||
},
|
||||
);
|
||||
if (result.status === 401) {
|
||||
throw new ApiError(401, 'Unauthorized');
|
||||
}
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
throw new ApiError(result.status, result.body || 'Avatar upload failed');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAvatar(): Promise<void> {
|
||||
await this.requestVoid('DELETE', '/humans/me/avatar');
|
||||
}
|
||||
|
||||
async getAvatarDownloadUrl(objectId: string): Promise<string> {
|
||||
const response = await this.fetch('GET', `/humans/avatar/${objectId}`);
|
||||
const data = await response.json();
|
||||
return data.url;
|
||||
}
|
||||
|
||||
// --- Push notification tokens ---
|
||||
|
||||
async registerPushToken(data: {
|
||||
|
||||
@@ -6,6 +6,7 @@ export const HumanSchema = z.object({
|
||||
email: z.string().email(),
|
||||
email_prefix: z.string(),
|
||||
email_notifications_enabled: z.boolean(),
|
||||
avatar_object_id: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type Human = z.infer<typeof HumanSchema>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Text, View } from 'react-native';
|
||||
import { Image, Text, View } from 'react-native';
|
||||
import type { Human } from '@/api/types';
|
||||
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -23,9 +24,10 @@ const sizeMap: Record<Size, { box: string; text: string; ring: number }> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Initials avatar with optional online ring (green) and an optional outer
|
||||
* stack ring used to visually separate overlapping avatars on a busy chrome.
|
||||
* Matches desktop's avatar + presence pattern (`ring-2 ring-green-500`).
|
||||
* Human avatar: renders the profile picture when one is set (resolved to a
|
||||
* signed URL via React Query), otherwise initials. Supports an optional online
|
||||
* ring (green) and an outer stack separator ring used to keep overlapping
|
||||
* avatars distinct on busy chrome. Matches desktop's avatar + presence pattern.
|
||||
*/
|
||||
export function Avatar({
|
||||
humanId,
|
||||
@@ -36,12 +38,14 @@ export function Avatar({
|
||||
className,
|
||||
}: AvatarProps) {
|
||||
const { initials } = resolveHumanDisplay(humanId, humans);
|
||||
const human = humanId ? humans?.find((h) => h.id === humanId) : undefined;
|
||||
const avatarUrl = useAvatarUrl(human?.avatar_object_id);
|
||||
const dims = sizeMap[size];
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
'bg-black/15 items-center justify-center rounded-full',
|
||||
'bg-black/15 items-center justify-center overflow-hidden rounded-full',
|
||||
dims.box,
|
||||
className,
|
||||
)}
|
||||
@@ -52,9 +56,13 @@ export function Avatar({
|
||||
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
|
||||
}}
|
||||
>
|
||||
<Text className={cn('text-white font-semibold', dims.text)}>
|
||||
{initials}
|
||||
</Text>
|
||||
{avatarUrl ? (
|
||||
<Image source={{ uri: avatarUrl }} className="h-full w-full" />
|
||||
) : (
|
||||
<Text className={cn('text-white font-semibold', dims.text)}>
|
||||
{initials}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { skipToken, useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/api/client';
|
||||
|
||||
/**
|
||||
* Resolve an avatar object id to a signed download URL. React Query handles
|
||||
* caching and de-duping, so many avatars sharing an id make a single request.
|
||||
* Mirrors desktop's use-avatar-url.
|
||||
*/
|
||||
export function useAvatarUrl(
|
||||
objectId: string | null | undefined,
|
||||
): string | undefined {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['avatar-url', objectId],
|
||||
queryFn: objectId
|
||||
? () => apiClient.getAvatarDownloadUrl(objectId)
|
||||
: skipToken,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/api/client';
|
||||
|
||||
/** Pending invitations sent for a network (member-visible). */
|
||||
export function useNetworkInvitations(networkId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['network-invitations', networkId],
|
||||
queryFn: () => apiClient.listNetworkInvitations(networkId),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite people by email. Existing users join directly; others get a pending
|
||||
* invitation. Refreshes both the network (new members) and its invitation list.
|
||||
*/
|
||||
export function useAddMembers(networkId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (emails: string[]) =>
|
||||
apiClient.addMembers(networkId, { email_addresses: emails }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['networks'] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['network-invitations', networkId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a member from the network (admin only). */
|
||||
export function useRemoveMember(networkId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (humanId: string) => apiClient.removeMember(networkId, humanId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['networks'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Revoke a pending invitation by email. */
|
||||
export function useRevokeInvitation(networkId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (email: string) =>
|
||||
apiClient.revokeInvitation(networkId, { email }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['network-invitations', networkId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { StreamListScreen } from '@/features/streams/StreamListScreen';
|
||||
import { NewStreamScreen } from '@/features/streams/NewStreamScreen';
|
||||
import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen';
|
||||
import { HuddleScreen } from '@/features/huddle/HuddleScreen';
|
||||
import { NetworkSettingsScreen } from '@/features/network-settings/NetworkSettingsScreen';
|
||||
import { SettingsScreen } from '@/features/settings/SettingsScreen';
|
||||
import { AccountScreen } from '@/features/settings/AccountScreen';
|
||||
import type { RootStackParamList } from './types';
|
||||
@@ -54,6 +55,11 @@ export function RootNavigator() {
|
||||
component={NewStreamScreen}
|
||||
options={{ animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="NetworkSettings"
|
||||
component={NetworkSettingsScreen}
|
||||
options={{ animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen name="Settings" component={SettingsScreen} />
|
||||
<Stack.Screen name="Account" component={AccountScreen} />
|
||||
</Stack.Navigator>
|
||||
|
||||
@@ -15,6 +15,7 @@ export type RootStackParamList = {
|
||||
serverUrl: string;
|
||||
};
|
||||
NewStream: { networkId: string };
|
||||
NetworkSettings: { networkId: string };
|
||||
Settings: undefined;
|
||||
Account: undefined;
|
||||
};
|
||||
|
||||
@@ -63,6 +63,8 @@ interface AuthState {
|
||||
requestCode: (email: string) => Promise<void>;
|
||||
signIn: (email: string, code: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
/** Re-fetch the current user from Orion (e.g. after an avatar change). */
|
||||
refreshUser: () => Promise<void>;
|
||||
/**
|
||||
* Wipes the session in response to a server-detected auth failure (e.g. a
|
||||
* 401 surfaced through react-query). Does not call `/auth/sign-out`; the
|
||||
@@ -166,6 +168,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
refreshUser: async () => {
|
||||
try {
|
||||
const user = await apiClient.me();
|
||||
set({ user });
|
||||
} catch (err) {
|
||||
logError(err, { scope: 'auth.refreshUser' });
|
||||
}
|
||||
},
|
||||
|
||||
invalidateSession: async () => {
|
||||
stopPushTokenSync();
|
||||
apiClient.setToken(null);
|
||||
|
||||
@@ -4278,6 +4278,18 @@ expo-haptics@~15.0.7:
|
||||
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3"
|
||||
integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==
|
||||
|
||||
expo-image-loader@~6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072"
|
||||
integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==
|
||||
|
||||
expo-image-picker@~17.0.8:
|
||||
version "17.0.11"
|
||||
resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.11.tgz#8f1537df946f7c19747ba9d0043ef7e3e8cb67dc"
|
||||
integrity sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==
|
||||
dependencies:
|
||||
expo-image-loader "~6.0.0"
|
||||
|
||||
expo-keep-awake@~15.0.8:
|
||||
version "15.0.8"
|
||||
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"
|
||||
|
||||
Reference in New Issue
Block a user