import { Buffer } from "buffer"; import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ProjectIconSource } from "@getpaseo/protocol/messages"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { ProjectIconView } from "@/components/project-icon-view"; import { Button } from "@/components/ui/button"; import type { FieldControlSize } from "@/components/ui/control-geometry"; import { Field, FormTextInput } from "@/components/ui/form-field"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-context"; import { useFilePicker } from "@/hooks/use-file-picker"; import { openProjectEditForm, type ProjectEditFormError, type ProjectEditFormSnapshot, type ProjectEditSubmission, type ProjectIconIntent, } from "@/projects/edit-form"; import { importProjectIconFromUrl } from "@/projects/import-project-icon"; import { toErrorMessage } from "@/utils/error-messages"; export interface ProjectEditSheetProps { visible: boolean; onClose: () => void; serverId: string; projectId: string; projectViewKey: string; client: DaemonClient; /** False on hosts that predate custom project icons — the icon field is hidden. */ supportsCustomIcon: boolean; snapshot: ProjectEditFormSnapshot; } /** Editing a project is its name and its icon, decided together and saved once. */ export function ProjectEditSheet({ visible, onClose, serverId, projectId, projectViewKey, client, supportsCustomIcon, snapshot, }: ProjectEditSheetProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const toast = useToast(); const { pickFiles } = useFilePicker(); const size: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm"; const [form] = useState(() => openProjectEditForm(snapshot)); const state = useSyncExternalStore(form.subscribe, form.getState, form.getState); const mutation = useMutation({ mutationFn: (submission: ProjectEditSubmission) => submitProjectEdit({ client, projectId, submission }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); queryClient.invalidateQueries({ queryKey: ["projectIcon", serverId] }); toast.show(t("settings.project.edit.savedToast"), { variant: "success" }); onClose(); }, onError: (error) => form.setError(toFormError(error)), }); const isSaving = mutation.isPending; const handleClose = useCallback(() => { if (isSaving) return; onClose(); }, [isSaving, onClose]); const handleSubmit = useCallback(() => { mutation.mutate(state.submission); }, [mutation, state.submission]); const chooseImage = useCallback(async () => { const file = (await pickFiles())?.[0]; if (!file) return; form.setPickedImage({ fileName: file.fileName, mimeType: file.mimeType, data: Buffer.from(file.bytes).toString("base64"), }); }, [form, pickFiles]); const handleChooseImage = useCallback(() => { void chooseImage(); }, [chooseImage]); const header = useMemo(() => ({ title: t("settings.project.edit.title") }), [t]); const footer = useMemo( () => ( ), [handleClose, handleSubmit, isSaving, state.canSubmit, t], ); return ( {supportsCustomIcon ? ( {state.canUseAutomatic ? ( ) : null} ) : null} ); } function projectInitial(projectName: string): string { return projectName.trim().charAt(0).toUpperCase() || "?"; } class ProjectEditStepError extends Error { constructor( readonly scope: ProjectEditFormError["scope"], cause: unknown, ) { super(toErrorMessage(cause)); this.name = "ProjectEditStepError"; } } async function step(scope: ProjectEditFormError["scope"], run: () => Promise): Promise { try { return await run(); } catch (error) { throw new ProjectEditStepError(scope, error); } } /** * One submit, up to two RPCs. Icon bytes are acquired before either RPC runs * because that is the step most likely to fail; past that point both RPCs take * the same inputs on a retry, so a failure between them is safe to submit again. */ async function submitProjectEdit(input: { client: DaemonClient; projectId: string; submission: ProjectEditSubmission; }): Promise { const { client, projectId, submission } = input; const { rename, icon } = submission; const source = icon ? await step("icon", () => acquireIconSource(icon)) : null; if (rename) { await step("name", () => client.renameProject(projectId, rename.customName)); } if (source) { await step("icon", () => client.setProjectIcon(projectId, source)); } } function acquireIconSource(intent: ProjectIconIntent): Promise { if (intent.type === "url") return importProjectIconFromUrl(intent.url); return Promise.resolve(intent); } function toFormError(error: unknown): ProjectEditFormError { if (error instanceof ProjectEditStepError) { return { scope: error.scope, message: error.message }; } return { scope: "name", message: toErrorMessage(error) }; } const styles = StyleSheet.create((theme) => ({ iconField: { gap: theme.spacing[2], }, iconRow: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: theme.spacing[2], }, previewText: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, }, footer: { flex: 1, flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2], }, footerButton: { minWidth: 112, }, }));