diff --git a/js/desktop/src/api/client.ts b/js/desktop/src/api/client.ts index f62ab6b..04aefbb 100644 --- a/js/desktop/src/api/client.ts +++ b/js/desktop/src/api/client.ts @@ -42,16 +42,12 @@ class ApiClient { this.config = config; } - private async fetch( + private async send( method: string, path: string, - body?: unknown, + init: { headers?: Record; body?: BodyInit } = {}, ): Promise { - const headers: Record = {}; - - if (body) { - headers['Content-Type'] = 'application/json'; - } + const headers: Record = { ...init.headers }; const token = this.config.getToken(); if (token) { @@ -61,7 +57,7 @@ class ApiClient { const response = await fetch(`${this.config.baseUrl}${path}`, { method, headers, - body: body ? JSON.stringify(body) : undefined, + body: init.body, }); if (response.status === 401) { @@ -77,6 +73,17 @@ class ApiClient { return response; } + private async fetch( + method: string, + path: string, + body?: unknown, + ): Promise { + return this.send(method, path, { + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + } + private async request( schema: z.ZodType, method: string, @@ -137,6 +144,25 @@ class ApiClient { await this.requestVoid('PATCH', '/humans/me/settings', data); } + // --- Avatar --- + + async updateAvatar(blob: Blob): Promise { + await this.send('PUT', '/humans/me/avatar', { + headers: { 'Content-Type': blob.type || 'image/jpeg' }, + body: blob, + }); + } + + 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; + } + // --- Depot --- async prepareUpload(data: PrepareUploadRequest) { diff --git a/js/desktop/src/api/types.ts b/js/desktop/src/api/types.ts index 7410cbb..ab63df4 100644 --- a/js/desktop/src/api/types.ts +++ b/js/desktop/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/desktop/src/components/human-avatar.tsx b/js/desktop/src/components/human-avatar.tsx new file mode 100644 index 0000000..9f4d982 --- /dev/null +++ b/js/desktop/src/components/human-avatar.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useAvatarUrl } from '@/hooks/use-avatar-url'; + +interface HumanAvatarProps + extends Omit, 'children'> { + /** Object id of the human's profile picture, if any. */ + avatarObjectId?: string | null; + /** Initials rendered while loading or when no picture is set. */ + initials: string; + /** Extra classes for the initials fallback. */ + fallbackClassName?: string; +} + +/** + * Renders a human's avatar: their profile picture when set (resolved to a + * signed URL), otherwise their initials. The fallback also shows while the + * image loads or if it fails, so this is a drop-in for the initials-only + * usages throughout the app. + */ +export function HumanAvatar({ + avatarObjectId, + initials, + fallbackClassName, + ...props +}: HumanAvatarProps) { + const url = useAvatarUrl(avatarObjectId); + return ( + + {url && } + {initials} + + ); +} diff --git a/js/desktop/src/features/network-settings.tsx b/js/desktop/src/features/network-settings.tsx index 76586ee..c2134e3 100644 --- a/js/desktop/src/features/network-settings.tsx +++ b/js/desktop/src/features/network-settings.tsx @@ -11,6 +11,7 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -45,11 +46,11 @@ function MemberRow({ return (
- - - {initials} - - +

{human.email_prefix}

{human.email} diff --git a/js/desktop/src/features/particles/particle-list-view.tsx b/js/desktop/src/features/particles/particle-list-view.tsx index 7653013..2b64574 100644 --- a/js/desktop/src/features/particles/particle-list-view.tsx +++ b/js/desktop/src/features/particles/particle-list-view.tsx @@ -26,7 +26,7 @@ import { useAuthStore } from '@/stores/auth-store'; import { particlePath } from '@/lib/particle-path'; import { resolveHumanDisplay } from '@/lib/humans'; import { RelativeTimestamp } from '@/components/relative-timestamp'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { Separator } from '@/components/ui/separator'; import { Progress } from '@/components/ui/progress'; import { Small } from '@/components/ui/typography'; @@ -150,7 +150,7 @@ const StreamRow = memo(function StreamRow({ particle.visible_to.length === 2 && particle.visible_to.every((v) => v.startsWith('human:')); - const initials = useMemo(() => { + const avatar = useMemo(() => { if (isDM) { const otherEntry = particle.visible_to.find( (v) => v !== `human:${userId}`, @@ -158,7 +158,12 @@ const StreamRow = memo(function StreamRow({ if (otherEntry) { const otherId = otherEntry.replace('human:', ''); const otherHuman = network?.humans?.find((h) => h.id === otherId); - if (otherHuman) return getInitials(otherHuman.email); + if (otherHuman) { + return { + initials: getInitials(otherHuman.email), + avatarObjectId: otherHuman.avatar_object_id ?? null, + }; + } } } @@ -166,10 +171,18 @@ const StreamRow = memo(function StreamRow({ const creator = network?.humans?.find( (h) => h.id === latestChild.created_by_human_id, ); - if (creator) return getInitials(creator.email); + if (creator) { + return { + initials: getInitials(creator.email), + avatarObjectId: creator.avatar_object_id ?? null, + }; + } } - return particle.properties.name.slice(0, 2).toUpperCase(); + return { + initials: particle.properties.name.slice(0, 2).toUpperCase(), + avatarObjectId: null, + }; }, [ isDM, particle.visible_to, @@ -242,11 +255,12 @@ const StreamRow = memo(function StreamRow({ {videoThumbObjectId ? ( ) : ( - - - {initials} - - + )}
diff --git a/js/desktop/src/features/particles/playback-page-indicator.tsx b/js/desktop/src/features/particles/playback-page-indicator.tsx index 49f860f..71879a0 100644 --- a/js/desktop/src/features/particles/playback-page-indicator.tsx +++ b/js/desktop/src/features/particles/playback-page-indicator.tsx @@ -1,4 +1,4 @@ -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { Tooltip, TooltipContent, @@ -161,18 +161,16 @@ function SegmentPresenceAvatars({ {visible.map((human) => ( - - - {human.emailPrefix.slice(0, 2).toUpperCase()} - - + avatarObjectId={human.avatarObjectId} + initials={human.emailPrefix.slice(0, 2).toUpperCase()} + /> {human.email} diff --git a/js/desktop/src/features/particles/reaction-bar.tsx b/js/desktop/src/features/particles/reaction-bar.tsx index 8671f9a..339470f 100644 --- a/js/desktop/src/features/particles/reaction-bar.tsx +++ b/js/desktop/src/features/particles/reaction-bar.tsx @@ -5,7 +5,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types'; import { cn } from '@/lib/utils'; import { resolveHumanDisplay } from '@/lib/humans'; @@ -113,11 +113,13 @@ export function ReactionBar({ : 'bg-black/40 hover:bg-black/50', )} > - - - {firstReactor.initials} - - + {text} {reactors.length > 1 && ( diff --git a/js/desktop/src/features/particles/stream-card.tsx b/js/desktop/src/features/particles/stream-card.tsx index d284f12..e133913 100644 --- a/js/desktop/src/features/particles/stream-card.tsx +++ b/js/desktop/src/features/particles/stream-card.tsx @@ -8,7 +8,7 @@ import type { Particle, StreamProperties } from '@/api/types'; import { useStreamAutoplay } from '@/hooks/use-stream-autoplay'; import { ParticlePreview } from '@/features/particles/particle-preview'; import { useNetwork } from '@/hooks/use-networks'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { RelativeTimestamp } from '@/components/relative-timestamp'; import { Small } from '@/components/ui/typography'; @@ -41,7 +41,7 @@ export const StreamCard = forwardRef( particle.visible_to.length === 2 && particle.visible_to.every((v) => v.startsWith('human:')); - const initials = useMemo(() => { + const avatar = useMemo(() => { if (isDM) { const otherEntry = particle.visible_to.find( (v) => v !== `human:${userId}`, @@ -49,7 +49,12 @@ export const StreamCard = forwardRef( if (otherEntry) { const otherId = otherEntry.replace('human:', ''); const otherHuman = network?.humans?.find((h) => h.id === otherId); - if (otherHuman) return getInitials(otherHuman.email); + if (otherHuman) { + return { + initials: getInitials(otherHuman.email), + avatarObjectId: otherHuman.avatar_object_id ?? null, + }; + } } } @@ -57,10 +62,18 @@ export const StreamCard = forwardRef( const creator = network?.humans?.find( (h) => h.id === latestChild.created_by_human_id, ); - if (creator) return getInitials(creator.email); + if (creator) { + return { + initials: getInitials(creator.email), + avatarObjectId: creator.avatar_object_id ?? null, + }; + } } - return particle.properties.name.slice(0, 2).toUpperCase(); + return { + initials: particle.properties.name.slice(0, 2).toUpperCase(), + avatarObjectId: null, + }; }, [ isDM, particle.visible_to, @@ -132,13 +145,12 @@ export const StreamCard = forwardRef( {/* Info bar */}
- - - {initials} - - + avatarObjectId={avatar.avatarObjectId} + initials={avatar.initials} + fallbackClassName="bg-primary/10 text-primary text-[10px] font-medium" + /> - - - {display.initials} - - + - - - {getInitials(human.email)} - - + {human.email_prefix} diff --git a/js/desktop/src/features/particles/stream-top-bar.tsx b/js/desktop/src/features/particles/stream-top-bar.tsx index e5cca4b..0985ed5 100644 --- a/js/desktop/src/features/particles/stream-top-bar.tsx +++ b/js/desktop/src/features/particles/stream-top-bar.tsx @@ -4,7 +4,8 @@ import { useAuthStore } from '@/stores/auth-store'; import { apiClient } from '@/api/client'; import { isParticleDeleted, type Particle } from '@/api/types'; import { particlePath, toFirestoreDocPath } from '@/lib/particle-path'; -import { Avatar, AvatarFallback, AvatarGroup } from '@/components/ui/avatar'; +import { AvatarGroup } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { Tooltip, TooltipContent, @@ -147,11 +148,12 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) { return ( - - - {display.initials} - - + {display.email} @@ -313,11 +315,13 @@ function MembersIndicator({ <> {shownMembers.map((human) => ( - - - {resolveHumanDisplay(human.id, humans).initials} - - + ))} {overflow > 0 && ( @@ -355,9 +359,12 @@ function ParticleBreadcrumbContent({ return ( - - {display.initials} - + {display.displayName} - ); diff --git a/js/desktop/src/features/settings-page.tsx b/js/desktop/src/features/settings-page.tsx index b5cec58..f8dc62f 100644 --- a/js/desktop/src/features/settings-page.tsx +++ b/js/desktop/src/features/settings-page.tsx @@ -11,8 +11,10 @@ import { FileText, Volume2, ArrowLeft, + Camera, } from 'lucide-react'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; +import { AvatarEditDialog } from '@/features/settings/avatar-edit-dialog'; import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; import { WindowControls } from '@/components/window-controls'; @@ -88,6 +90,7 @@ export default function SettingsPage() { const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled); const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled); const [version, setVersion] = useState(); + const [avatarOpen, setAvatarOpen] = useState(false); useEffect(() => { platform.app.getVersion().then(setVersion); @@ -135,17 +138,30 @@ export default function SettingsPage() { {/* Profile header */}
- - - {initials} - - +

{user?.email_prefix}

{user?.email}
+ + diff --git a/js/desktop/src/features/settings/avatar-edit-dialog.tsx b/js/desktop/src/features/settings/avatar-edit-dialog.tsx new file mode 100644 index 0000000..2bdd767 --- /dev/null +++ b/js/desktop/src/features/settings/avatar-edit-dialog.tsx @@ -0,0 +1,337 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Camera, Loader2, Trash2, Upload } from 'lucide-react'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { Muted } from '@/components/ui/typography'; +import { apiClient } from '@/api/client'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAvatarUrl } from '@/hooks/use-avatar-url'; +import { useFileInput } from '@/hooks/use-file-input'; +import { toAvatarBlob } from '@/lib/avatar-image'; +import { logError, toUserMessage } from '@/lib/errors'; +import { cn } from '@/lib/utils'; + +// Guard the *source* file before decoding so we never load a huge image into +// memory just to throw most of it away — the uploaded blob is always our small +// re-encoded square regardless of input size. +const MAX_SOURCE_BYTES = 30 * 1024 * 1024; + +interface AvatarEditDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps) { + const user = useAuthStore((s) => s.user); + const refreshUser = useAuthStore((s) => s.refreshUser); + const currentUrl = useAvatarUrl(user?.avatar_object_id); + const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? '?'; + const hasAvatar = !!user?.avatar_object_id; + + const [tab, setTab] = useState<'upload' | 'camera'>('upload'); + const [prepared, setPrepared] = useState<{ blob: Blob; url: string } | null>( + null, + ); + const [busy, setBusy] = useState(false); + + // Keep the latest prepared blob in a ref so the unmount cleanup can revoke + // its object URL without re-running on every change. + const preparedRef = useRef(prepared); + useEffect(() => { + preparedRef.current = prepared; + }, [prepared]); + useEffect( + () => () => { + if (preparedRef.current) URL.revokeObjectURL(preparedRef.current.url); + }, + [], + ); + + const setPreparedFromBlob = useCallback((blob: Blob) => { + setPrepared((prev) => { + if (prev) URL.revokeObjectURL(prev.url); + return { blob, url: URL.createObjectURL(blob) }; + }); + }, []); + + // Close and reset to a clean slate so reopening starts fresh. + const close = useCallback(() => { + setPrepared((prev) => { + if (prev) URL.revokeObjectURL(prev.url); + return null; + }); + setTab('upload'); + setBusy(false); + onOpenChange(false); + }, [onOpenChange]); + + const handleOpenChange = useCallback( + (next: boolean) => { + if (next) onOpenChange(true); + else close(); + }, + [close, onOpenChange], + ); + + const handleFiles = useCallback( + async (files: File[]) => { + const file = files[0]; + if (!file) return; + if (!file.type.startsWith('image/')) { + toast.error('Please choose an image file.'); + return; + } + if (file.size > MAX_SOURCE_BYTES) { + toast.error('That image is too large — choose one under 30MB.'); + return; + } + try { + const bitmap = await createImageBitmap(file); + const blob = await toAvatarBlob(bitmap); + bitmap.close(); + setPreparedFromBlob(blob); + } catch (err) { + toast.error('Could not process that image.'); + logError(err, { scope: 'avatar.processFile' }); + } + }, + [setPreparedFromBlob], + ); + + const { openFilePicker, isDragging, dropZoneProps } = useFileInput({ + onFilesSelected: handleFiles, + enabled: open && tab === 'upload', + }); + + const handleSave = async () => { + if (!prepared) return; + setBusy(true); + try { + await apiClient.updateAvatar(prepared.blob); + await refreshUser(); + toast.success('Avatar updated'); + close(); + } catch (err) { + toast.error(toUserMessage(err)); + logError(err, { scope: 'avatar.save' }); + setBusy(false); + } + }; + + const handleRemove = async () => { + setBusy(true); + try { + await apiClient.deleteAvatar(); + await refreshUser(); + toast.success('Avatar removed'); + close(); + } catch (err) { + toast.error(toUserMessage(err)); + logError(err, { scope: 'avatar.remove' }); + setBusy(false); + } + }; + + const previewUrl = prepared?.url ?? currentUrl; + + return ( + + + + Profile picture + + Upload an image or take one with your camera. + + + +
+
+ {previewUrl ? ( + + ) : ( + + {initials} + + )} +
+ + setTab(v as 'upload' | 'camera')} + className="w-full" + > + + + + Upload + + + + Take photo + + + + +
+ Drag an image here, or + +
+
+ + + + +
+
+ + + {hasAvatar && ( + + )} + + + +
+
+ ); +} + +function CameraCapture({ + active, + onCapture, +}: { + active: boolean; + onCapture: (blob: Blob) => void; +}) { + const videoRef = useRef(null); + const [stream, setStream] = useState(null); + const [error, setError] = useState(null); + const [capturing, setCapturing] = useState(false); + + useEffect(() => { + if (!active) return; + + let cancelled = false; + let acquired: MediaStream | null = null; + + navigator.mediaDevices + .getUserMedia({ video: { aspectRatio: { ideal: 1 } }, audio: false }) + .then((s) => { + if (cancelled) { + s.getTracks().forEach((t) => t.stop()); + return; + } + acquired = s; + setStream(s); + setError(null); + }) + .catch((err: unknown) => { + if (cancelled) return; + setError( + err instanceof Error ? err.message : 'Unable to access camera', + ); + }); + + return () => { + cancelled = true; + acquired?.getTracks().forEach((t) => t.stop()); + setStream(null); + }; + }, [active]); + + useEffect(() => { + if (videoRef.current) videoRef.current.srcObject = stream; + }, [stream]); + + const handleCapture = async () => { + const video = videoRef.current; + if (!video) return; + setCapturing(true); + try { + const bitmap = await createImageBitmap(video); + // Mirror to match the (mirrored) live preview the user is looking at. + const blob = await toAvatarBlob(bitmap, { mirror: true }); + bitmap.close(); + onCapture(blob); + } catch (err) { + toast.error('Could not capture photo.'); + logError(err, { scope: 'avatar.capture' }); + } finally { + setCapturing(false); + } + }; + + if (error) { + return ( +
+ Couldn't access your camera. + {error} +
+ ); + } + + return ( +
+
+
+ +
+ ); +} diff --git a/js/desktop/src/hooks/use-avatar-url.ts b/js/desktop/src/hooks/use-avatar-url.ts new file mode 100644 index 0000000..5984366 --- /dev/null +++ b/js/desktop/src/hooks/use-avatar-url.ts @@ -0,0 +1,20 @@ +import { useQuery, skipToken } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; + +/** + * Resolves an avatar object id to a signed download URL. Mirrors + * {@link import('./use-download-url').useDownloadUrl} — React Query handles + * caching and de-duping, so many avatars sharing an id make a single request. + */ +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/desktop/src/hooks/use-presence-positions.ts b/js/desktop/src/hooks/use-presence-positions.ts index 3ad9ea7..13d136b 100644 --- a/js/desktop/src/hooks/use-presence-positions.ts +++ b/js/desktop/src/hooks/use-presence-positions.ts @@ -5,6 +5,7 @@ export interface HumanPresence { humanId: string; email: string; emailPrefix: string; + avatarObjectId: string | null; } /** @@ -42,6 +43,7 @@ export function usePresencePositions( humanId: human.id, email: human.email, emailPrefix: human.email_prefix, + avatarObjectId: human.avatar_object_id ?? null, }; if (existing) { existing.push(presence); diff --git a/js/desktop/src/lib/avatar-image.ts b/js/desktop/src/lib/avatar-image.ts new file mode 100644 index 0000000..14c41a8 --- /dev/null +++ b/js/desktop/src/lib/avatar-image.ts @@ -0,0 +1,28 @@ +/** + * Center-crop an image source to a square and downscale it to a JPEG suitable + * for an avatar. Avatars never render larger than ~80px, so 512px is generous + * headroom while keeping the upload to a few tens of KB regardless of input. + * + * Pass `mirror` when capturing from a (mirrored) webcam preview so the saved + * image matches what the user saw. + */ +export async function toAvatarBlob( + source: ImageBitmap, + { size = 512, mirror = false }: { size?: number; mirror?: boolean } = {}, +): Promise { + const side = Math.min(source.width, source.height); + const sx = (source.width - side) / 2; + const sy = (source.height - side) / 2; + + const canvas = new OffscreenCanvas(size, size); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to acquire 2D canvas context'); + + if (mirror) { + ctx.translate(size, 0); + ctx.scale(-1, 1); + } + ctx.drawImage(source, sx, sy, side, side, 0, 0, size, size); + + return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.82 }); +} diff --git a/js/desktop/src/lib/humans.ts b/js/desktop/src/lib/humans.ts index c25284c..2e710a8 100644 --- a/js/desktop/src/lib/humans.ts +++ b/js/desktop/src/lib/humans.ts @@ -13,6 +13,8 @@ export interface HumanDisplay { email: string; /** Initials for avatar fallback. */ initials: string; + /** Avatar object id, when the human has a profile picture set. */ + avatarObjectId: string | null; } /** @@ -32,6 +34,7 @@ export function resolveHumanDisplay( displayName: REMOVED_MEMBER_LABEL, email: REMOVED_MEMBER_LABEL, initials: REMOVED_MEMBER_INITIALS, + avatarObjectId: null, }; } return { @@ -39,5 +42,6 @@ export function resolveHumanDisplay( displayName: human.email_prefix, email: human.email, initials: getInitials(human.email), + avatarObjectId: human.avatar_object_id ?? null, }; } diff --git a/js/desktop/src/stores/auth-store.ts b/js/desktop/src/stores/auth-store.ts index 6a46974..9769cb0 100644 --- a/js/desktop/src/stores/auth-store.ts +++ b/js/desktop/src/stores/auth-store.ts @@ -30,6 +30,7 @@ interface AuthState { isSigningOut: boolean; error: string | null; restoreSession: () => Promise; + refreshUser: () => Promise; requestCode: (email: string) => Promise; signIn: (email: string, code: string) => Promise; signOut: () => Promise; @@ -64,6 +65,11 @@ export const useAuthStore = create((set) => ({ } }, + refreshUser: async () => { + const user = await apiClient.me(); + set({ user }); + }, + requestCode: async (email: string) => { set({ isRequestingCode: true, error: null }); try {