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
+7
View File
@@ -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',
{
+1
View File
@@ -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",
+40
View File
@@ -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: {
+1
View File
@@ -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>;
+16 -8
View File
@@ -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">
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>
);
}
+20
View File
@@ -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>
+1
View File
@@ -15,6 +15,7 @@ export type RootStackParamList = {
serverUrl: string;
};
NewStream: { networkId: string };
NetworkSettings: { networkId: string };
Settings: undefined;
Account: undefined;
};
+11
View File
@@ -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);
+12
View File
@@ -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"