From 6885ca355b9874630b5af827d14e23e74256f0b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:53:31 +0000 Subject: [PATCH] mobile: network member management and avatars (parity phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV --- js/mobile/app.config.ts | 7 + js/mobile/package.json | 1 + js/mobile/src/api/client.ts | 40 ++++ js/mobile/src/api/types.ts | 1 + js/mobile/src/components/Avatar.tsx | 24 +- .../network-settings/AddMembersSheet.tsx | 145 ++++++++++++ .../NetworkSettingsScreen.tsx | 213 ++++++++++++++++++ .../src/features/settings/AccountScreen.tsx | 101 ++++++++- .../src/features/streams/StreamListScreen.tsx | 23 +- js/mobile/src/hooks/use-avatar-url.ts | 20 ++ js/mobile/src/hooks/use-member-management.ts | 53 +++++ js/mobile/src/navigation/RootNavigator.tsx | 6 + js/mobile/src/navigation/types.ts | 1 + js/mobile/src/stores/auth-store.ts | 11 + js/mobile/yarn.lock | 12 + 15 files changed, 646 insertions(+), 12 deletions(-) create mode 100644 js/mobile/src/features/network-settings/AddMembersSheet.tsx create mode 100644 js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx create mode 100644 js/mobile/src/hooks/use-avatar-url.ts create mode 100644 js/mobile/src/hooks/use-member-management.ts diff --git a/js/mobile/app.config.ts b/js/mobile/app.config.ts index 16403ec..0b077c3 100644 --- a/js/mobile/app.config.ts +++ b/js/mobile/app.config.ts @@ -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', { diff --git a/js/mobile/package.json b/js/mobile/package.json index 17be350..ced1e56 100644 --- a/js/mobile/package.json +++ b/js/mobile/package.json @@ -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", diff --git a/js/mobile/src/api/client.ts b/js/mobile/src/api/client.ts index 27da3e9..a6bbcd4 100644 --- a/js/mobile/src/api/client.ts +++ b/js/mobile/src/api/client.ts @@ -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 { + const headers: Record = { '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 { + await this.requestVoid('DELETE', '/humans/me/avatar'); + } + + async getAvatarDownloadUrl(objectId: string): Promise { + const response = await this.fetch('GET', `/humans/avatar/${objectId}`); + const data = await response.json(); + return data.url; + } + // --- Push notification tokens --- async registerPushToken(data: { diff --git a/js/mobile/src/api/types.ts b/js/mobile/src/api/types.ts index 7410cbb..ab63df4 100644 --- a/js/mobile/src/api/types.ts +++ b/js/mobile/src/api/types.ts @@ -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; diff --git a/js/mobile/src/components/Avatar.tsx b/js/mobile/src/components/Avatar.tsx index 8655682..d52df07 100644 --- a/js/mobile/src/components/Avatar.tsx +++ b/js/mobile/src/components/Avatar.tsx @@ -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 = { }; /** - * 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 ( - - {initials} - + {avatarUrl ? ( + + ) : ( + + {initials} + + )} ); } diff --git a/js/mobile/src/features/network-settings/AddMembersSheet.tsx b/js/mobile/src/features/network-settings/AddMembersSheet.tsx new file mode 100644 index 0000000..9427c01 --- /dev/null +++ b/js/mobile/src/features/network-settings/AddMembersSheet.tsx @@ -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([]); + 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 ( + + + Add members + + Existing users join right away; others get an email invite. + + + {emails.length > 0 ? ( + + {emails.map((email) => ( + removeEmail(email)} + className="flex-row items-center bg-white/10 rounded-full pl-3 pr-2 py-1" + > + {email} + × + + ))} + + ) : null} + + { + if (text.endsWith(',') || text.endsWith(' ')) { + setDraft(text); + commitDraft(); + } else { + setDraft(text); + } + }} + placeholder="name@example.com" + 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" + /> + + + + {addMembers.isPending ? 'Sending…' : 'Send invites'} + + + + + ); +} diff --git a/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx b/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx new file mode 100644 index 0000000..0331a67 --- /dev/null +++ b/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx @@ -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 ( + + + navigation.goBack()} className="px-2 py-1"> + + + + {network?.name ?? 'Network'} + + + + + + + + + Members + + + {members.length} {members.length === 1 ? 'member' : 'members'} + + + {isAdmin ? ( + setAddOpen(true)} + className="flex-row items-center bg-primary rounded-full px-3 py-2" + > + + + Add + + + ) : null} + + + + {members.map((human) => { + const isRowAdmin = human.id === network?.admin_human.id; + const canRemove = + isAdmin && !isRowAdmin && human.id !== currentUserId; + return ( + + + + + {human.email_prefix} + + + {human.email} + + + {isRowAdmin ? ( + + + + Admin + + + ) : null} + {canRemove ? ( + handleRemove(human)} + hitSlop={8} + className="p-1" + accessibilityLabel={`Remove ${human.email}`} + > + + + ) : null} + + ); + })} + + + {isAdmin ? ( + + + Pending invitations + + {invitationsError ? ( + + Couldn’t load pending invitations. + + ) : pending.length === 0 ? ( + + No pending invitations. + + ) : ( + + {pending.map((inv) => ( + + ))} + + )} + + ) : null} + + + {isAdmin ? ( + setAddOpen(false)} + networkId={networkId} + /> + ) : null} + + ); +} + +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 ( + + + + + + + {email} + + Pending + + + + + + ); +} diff --git a/js/mobile/src/features/settings/AccountScreen.tsx b/js/mobile/src/features/settings/AccountScreen.tsx index e57f93a..bd28e4e 100644 --- a/js/mobile/src/features/settings/AccountScreen.tsx +++ b/js/mobile/src/features/settings/AccountScreen.tsx @@ -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 ( @@ -18,7 +85,37 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) { - + + + + {busy ? ( + + ) : avatarUrl ? ( + + ) : ( + + {initials} + + )} + + + + + + + {user?.avatar_object_id ? ( + + Remove photo + + ) : null} + + + diff --git a/js/mobile/src/features/streams/StreamListScreen.tsx b/js/mobile/src/features/streams/StreamListScreen.tsx index 1a8a3ad..280c429 100644 --- a/js/mobile/src/features/streams/StreamListScreen.tsx +++ b/js/mobile/src/features/streams/StreamListScreen.tsx @@ -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({
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 ( void }) { > {title} - + + + ); } diff --git a/js/mobile/src/hooks/use-avatar-url.ts b/js/mobile/src/hooks/use-avatar-url.ts new file mode 100644 index 0000000..3360359 --- /dev/null +++ b/js/mobile/src/hooks/use-avatar-url.ts @@ -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; +} diff --git a/js/mobile/src/hooks/use-member-management.ts b/js/mobile/src/hooks/use-member-management.ts new file mode 100644 index 0000000..fe426db --- /dev/null +++ b/js/mobile/src/hooks/use-member-management.ts @@ -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], + }); + }, + }); +} diff --git a/js/mobile/src/navigation/RootNavigator.tsx b/js/mobile/src/navigation/RootNavigator.tsx index 4cbb217..ac04756 100644 --- a/js/mobile/src/navigation/RootNavigator.tsx +++ b/js/mobile/src/navigation/RootNavigator.tsx @@ -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' }} /> + diff --git a/js/mobile/src/navigation/types.ts b/js/mobile/src/navigation/types.ts index f79ec26..876ab0d 100644 --- a/js/mobile/src/navigation/types.ts +++ b/js/mobile/src/navigation/types.ts @@ -15,6 +15,7 @@ export type RootStackParamList = { serverUrl: string; }; NewStream: { networkId: string }; + NetworkSettings: { networkId: string }; Settings: undefined; Account: undefined; }; diff --git a/js/mobile/src/stores/auth-store.ts b/js/mobile/src/stores/auth-store.ts index 852a61a..6f1c2f4 100644 --- a/js/mobile/src/stores/auth-store.ts +++ b/js/mobile/src/stores/auth-store.ts @@ -63,6 +63,8 @@ interface AuthState { requestCode: (email: string) => Promise; signIn: (email: string, code: string) => Promise; signOut: () => Promise; + /** Re-fetch the current user from Orion (e.g. after an avatar change). */ + refreshUser: () => Promise; /** * 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((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); diff --git a/js/mobile/yarn.lock b/js/mobile/yarn.lock index bc433ea..9ebf56e 100644 --- a/js/mobile/yarn.lock +++ b/js/mobile/yarn.lock @@ -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"