From fe0e8d4ee12e4759ef1cc49155885afc1f5fce3c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:46:11 -0700 Subject: [PATCH 01/53] fix(mobile): dragging a microphone or environment route now saves its new place (#26096) Co-authored-by: Claude Opus 5.5 --- .../SettingsMicrophoneRouteScreen.tsx | 100 ++++++++---------- .../components/SettingsDragHandle.test.tsx | 75 +++++++++++++ .../components/SettingsDragHandle.tsx | 21 ++-- apps/mobile/vite.config.ts | 1 + docs/user/composer.md | 2 +- 5 files changed, 132 insertions(+), 67 deletions(-) create mode 100644 apps/mobile/src/features/settings/components/SettingsDragHandle.test.tsx diff --git a/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx index adcf71595b177..b7ccdc0e00640 100644 --- a/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx @@ -1,14 +1,13 @@ -import { Button } from "../../components/ui/Button"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from "expo-audio"; import { useState } from "react"; -import { Linking, Pressable, View } from "react-native"; +import { Linking, useWindowDimensions, View } from "react-native"; import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { type AppSymbolName, SymbolView } from "../../components/ui/AppSymbol"; +import { type AppSymbolName } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { cn } from "../../lib/cn"; import { makePermissionStatusAtom } from "../../lib/permissionStatusAtom"; @@ -21,12 +20,11 @@ import { } from "../../lib/microphonePriority"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { ListRowLayout } from "../../components/ui/ListRowLayout"; +import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { SettingsActionRow } from "./components/SettingsActionRow"; import { SettingsDragHandle } from "./components/SettingsDragHandle"; import { SettingsSection } from "./components/SettingsSection"; -const REMOVE_SIZE = 20; - type MicrophonePermission = "checking" | "granted" | "ask" | "denied"; function toMicrophonePermission(response: { @@ -98,9 +96,7 @@ export function SettingsMicrophoneRouteScreen() { const entries = microphoneOrder( AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.microphones : undefined, ); - const hasDevices = entries.some(isMicrophoneDevice); const { permission, request: requestPermission } = useMicrophonePermission(); - const [editing, setEditing] = useState(false); const [drag, setDrag] = useState<{ readonly key: string; readonly translation: number } | null>( null, ); @@ -166,18 +162,7 @@ export function SettingsMicrophoneRouteScreen() { ) : null} - setEditing((value) => !value)} - /> - ) : undefined - } - > + {entries.map((entry, index) => { const key = microphoneEntryKey(entry); const shift = @@ -195,7 +180,6 @@ export function SettingsMicrophoneRouteScreen() { position={index + 1} count={entries.length} reorderable={ready} - editing={editing && hasDevices} offset={index === dragFrom ? (drag?.translation ?? 0) : shift} lifted={index === dragFrom} onHeight={setRowHeight} @@ -215,7 +199,7 @@ export function SettingsMicrophoneRouteScreen() { Voice input records from the first connected microphone in this list. Wired and Bluetooth devices are added by name after you use voice input with them connected; until then, the - Other row ranks them. + Other row ranks them. Swipe a device left to forget it. @@ -227,7 +211,6 @@ function MicrophoneRow(props: { readonly position: number; readonly count: number; readonly reorderable: boolean; - readonly editing: boolean; readonly offset: number; readonly lifted: boolean; readonly onHeight: (height: number) => void; @@ -237,9 +220,10 @@ function MicrophoneRow(props: { readonly onStep: (direction: "up" | "down") => void; readonly onForget: (() => void) | undefined; }) { - const { lifted, offset, entry } = props; + const { lifted, offset, entry, onForget } = props; const kind = KINDS[entry.kind]; const title = isMicrophoneDevice(entry) ? entry.name : kind.row; + const { width: windowWidth } = useWindowDimensions(); const style = useAnimatedStyle(() => ({ transform: [ { @@ -250,39 +234,16 @@ function MicrophoneRow(props: { ], zIndex: lifted ? 1 : 0, })); - return ( - props.onHeight(event.nativeEvent.layout.height)} - className={cn( - "flex-row items-center", - props.position > 1 && !lifted && "border-t border-border-subtle", - lifted && "bg-grouped-card shadow-md", - )} - > - {props.editing && props.onForget ? ( - - - - ) : props.editing ? ( - - - - ) : null} + const row = ( + // Opaque so the swipe actions stay hidden until the row slides over them. + { + if (nativeEvent.actionName === "forget") onForget?.(); + }} className="min-w-0 flex-1" > ) : null} + + ); + return ( + props.onHeight(event.nativeEvent.layout.height)} + className={cn( + props.position > 1 && !lifted && "border-t border-border-subtle", + lifted && "bg-grouped-card shadow-md", + )} + > + {onForget ? ( + + {() => row} + + ) : ( + row + )} ); } diff --git a/apps/mobile/src/features/settings/components/SettingsDragHandle.test.tsx b/apps/mobile/src/features/settings/components/SettingsDragHandle.test.tsx new file mode 100644 index 0000000000000..8c1f1384fa4a6 --- /dev/null +++ b/apps/mobile/src/features/settings/components/SettingsDragHandle.test.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vite-plus/test"; + +type PanEvent = { readonly translationY: number }; +type PanHandlers = { + onStart?: () => void; + onUpdate?: (event: PanEvent) => void; + onFinalize?: (event: PanEvent, success: boolean) => void; +}; + +const pans = vi.hoisted(() => [] as PanHandlers[]); + +vi.mock("react-native", () => ({ + View: ({ children }: { children?: ReactNode }) =>
{children}
, +})); +vi.mock("react-native-gesture-handler", () => ({ + GestureDetector: ({ children }: { children?: ReactNode }) => children, + Gesture: { + Pan: () => { + const handlers: PanHandlers = {}; + pans.push(handlers); + const pan = { + minDistance: () => pan, + shouldCancelWhenOutside: () => pan, + runOnJS: () => pan, + onStart: (callback: PanHandlers["onStart"]) => ((handlers.onStart = callback), pan), + onUpdate: (callback: PanHandlers["onUpdate"]) => ((handlers.onUpdate = callback), pan), + onFinalize: (callback: PanHandlers["onFinalize"]) => ( + (handlers.onFinalize = callback), + pan + ), + }; + return pan; + }, + }, +})); +vi.mock("../../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); + +import { SettingsDragHandle } from "./SettingsDragHandle"; + +describe("SettingsDragHandle", () => { + // The worklets Babel plugin copies values a gesture callback closes over when the gesture is + // built, so a drop must not read a translation tracked in a local variable. + it("reports the released translation when the pan ends", () => { + pans.length = 0; + const onEnd = vi.fn(); + const root = createRoot(document.createElement("div")); + act(() => + root.render( + {}} + onMove={() => {}} + onEnd={onEnd} + onStep={() => {}} + />, + ), + ); + const pan = pans.at(-1)!; + + act(() => { + pan.onStart?.(); + pan.onUpdate?.({ translationY: 64 }); + pan.onUpdate?.({ translationY: 130 }); + pan.onFinalize?.({ translationY: 130 }, true); + }); + + expect(onEnd).toHaveBeenCalledWith(130, false); + act(() => root.unmount()); + }); +}); diff --git a/apps/mobile/src/features/settings/components/SettingsDragHandle.tsx b/apps/mobile/src/features/settings/components/SettingsDragHandle.tsx index c27db4ce28e45..9a73983f9da0a 100644 --- a/apps/mobile/src/features/settings/components/SettingsDragHandle.tsx +++ b/apps/mobile/src/features/settings/components/SettingsDragHandle.tsx @@ -18,22 +18,17 @@ export function SettingsDragHandle(props: { const onEnd = useEffectEvent((translation: number, cancelled: boolean) => props.onEnd(translation, cancelled), ); - const [gesture] = useState(() => { - let translation = 0; - return Gesture.Pan() + // The worklets plugin copies the values these callbacks close over when the gesture is built, + // so the drop reads its translation from the final event rather than a tracked variable. + const [gesture] = useState(() => + Gesture.Pan() .minDistance(0) .shouldCancelWhenOutside(false) .runOnJS(true) - .onStart(() => { - translation = 0; - onStart(); - }) - .onUpdate((event) => { - translation = event.translationY; - onMove(event.translationY); - }) - .onFinalize((_, success) => onEnd(translation, !success)); - }); + .onStart(() => onStart()) + .onUpdate((event) => onMove(event.translationY)) + .onFinalize((event, success) => onEnd(event.translationY, !success)), + ); return ( Date: Tue, 27 Oct 2026 21:47:19 +0000 Subject: [PATCH 02/53] feat(web): channel artwork choice on Latest, plus nightly or dev art on any channel (#26138) --- apps/web/src/components/AppSidebarLayout.tsx | 4 +- .../src/components/SidebarStageBackdrop.tsx | 13 +- .../chat/ComposerPrimaryActions.tsx | 4 +- .../settings/AppearanceSettingsPanel.tsx | 132 +++++++++++------- .../src/components/settings/settingsSearch.ts | 2 +- .../src/components/sidebar/SidebarChrome.tsx | 2 +- apps/web/src/hooks/useSettings.ts | 3 +- packages/contracts/src/settings.ts | 10 +- 8 files changed, 108 insertions(+), 62 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index f7b3209f661ac..383c6b3123e86 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -290,9 +290,7 @@ function SidebarControl() { const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar(); const isSidebarVisible = useSidebarVisibility(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); - const stageBackdropVariant = useSidebarStageBackdropVariant( - environmentIdentificationMode === "artwork", - ); + const stageBackdropVariant = useSidebarStageBackdropVariant(environmentIdentificationMode); const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", { context: { usagePageOpen }, }); diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index b272c5724feb7..9aa7ecc9af694 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentIdentificationMode } from "@t3tools/contracts"; import { useId } from "react"; import { APP_STAGE_LABEL } from "../branding"; @@ -12,9 +13,11 @@ const STAGE_BACKDROP_VIEW_BOX = "0 0 8192 96"; export function resolveSidebarStageBackdropVariant( stageLabel: string, - enabled = true, + mode: EnvironmentIdentificationMode = "artwork", ): SidebarStageBackdropVariant | null { - if (!enabled) return null; + if (mode === "nightly-artwork") return "nightly"; + if (mode === "dev-artwork") return "dev"; + if (mode !== "artwork") return null; const normalized = stageLabel.trim().toLowerCase(); if (normalized === "nightly") return "nightly"; if (normalized === "dev") return "dev"; @@ -40,8 +43,10 @@ export function useEnvironmentStageLabel(): string { }); } -export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBackdropVariant | null { - return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), enabled); +export function useSidebarStageBackdropVariant( + mode: EnvironmentIdentificationMode, +): SidebarStageBackdropVariant | null { + return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), mode); } export function SidebarStageBackdrop({ variant }: { variant: SidebarStageBackdropVariant }) { diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 35af48b4de402..c612464e4812e 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -124,9 +124,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ }) === "queue"; const alternateAction = alternateComposerDispatchAction(followUpBehavior); const isSendDisabled = !canOperateThread || sendDisabledReason !== null; - const stageBackdropVariant = useSidebarStageBackdropVariant( - environmentIdentificationMode === "artwork", - ); + const stageBackdropVariant = useSidebarStageBackdropVariant(environmentIdentificationMode); const renderStopGenerationButton = (insidePendingAction: boolean) => ( diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx index e0877c8574acc..08722ec2b061e 100644 --- a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -13,7 +13,8 @@ import { MIN_PANEL_ANIMATION_DURATION_MS, } from "@t3tools/contracts/settings"; import { - resolveEnvironmentIdentificationPillLabel, + resolveSidebarStageBackdropVariant, + type SidebarStageBackdropVariant, useEnvironmentStageLabel, } from "../SidebarStageBackdrop"; import { useCustomThemes } from "../../hooks/useCustomThemes"; @@ -35,10 +36,40 @@ import { TypographySection } from "./TypographySettings"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", + "nightly-artwork": "Nightly artwork", + "dev-artwork": "Dev artwork", pill: "Version pill", none: "None", }; +// The default "artwork" follows the running channel, so it is labeled as that channel's art, +// or as "None" on Latest. The other channel's art is offered as a fixed choice. +function environmentIdentificationOptions( + stageVariant: SidebarStageBackdropVariant | null, +): ReadonlyArray { + if (stageVariant === "dev") { + return [ + ["artwork", "Dev artwork"], + ["nightly-artwork", "Nightly artwork"], + ["pill", "Version pill"], + ["none", "None"], + ]; + } + if (stageVariant === "nightly") { + return [ + ["artwork", "Nightly artwork"], + ["dev-artwork", "Dev artwork"], + ["pill", "Version pill"], + ["none", "None"], + ]; + } + return [ + ["artwork", "None"], + ["nightly-artwork", "Nightly artwork"], + ["dev-artwork", "Dev artwork"], + ]; +} + const CHAT_WIDTH_LABELS: Record = { comfortable: "Comfortable", wide: "Wide", @@ -61,8 +92,13 @@ export function AppearanceSettingsPanel() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const environmentStageLabel = useEnvironmentStageLabel(); - const showEnvironmentIdentification = - resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; + const environmentStageVariant = resolveSidebarStageBackdropVariant(environmentStageLabel); + const environmentIdentificationOptionList = + environmentIdentificationOptions(environmentStageVariant); + const environmentIdentificationLabel = + environmentIdentificationOptionList.find( + ([value]) => value === settings.environmentIdentificationMode, + )?.[1] ?? ENVIRONMENT_IDENTIFICATION_LABELS[settings.environmentIdentificationMode]; const glassOpacityRatio = (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); const glassOpacitySliderStyle = { @@ -316,52 +352,52 @@ export function AppearanceSettingsPanel() { ) : null } /> - {showEnvironmentIdentification ? ( - - updateSettings({ - environmentIdentificationMode: DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, - }) - } - /> - ) : null - } - control={ - { + const option = environmentIdentificationOptionList.find( + ([optionValue]) => optionValue === value, + ); + if (option) updateSettings({ environmentIdentificationMode: option[0] }); + }} + > + - - - {ENVIRONMENT_IDENTIFICATION_LABELS[settings.environmentIdentificationMode]} - - - - {Object.entries(ENVIRONMENT_IDENTIFICATION_LABELS).map(([value, label]) => ( - - {label} - - ))} - - - } - /> - ) : null} + {environmentIdentificationLabel} + + + {environmentIdentificationOptionList.map(([value, label]) => ( + + {label} + + ))} + + + } + /> Date: Wed, 28 Oct 2026 00:54:08 +0300 Subject: [PATCH 03/53] feat(mobile): browse ZIP attachments with the shared archive reader (#19463) Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../features/files/AttachmentDocumentBody.tsx | 111 +++ .../features/files/AttachmentFileScreen.tsx | 136 +-- .../features/files/ZipArchiveView.test.tsx | 315 +++++++ .../src/features/files/ZipArchiveView.tsx | 870 ++++++++++++++++++ apps/mobile/src/features/files/zipTree.ts | 139 +++ apps/mobile/src/lib/attachmentDownload.ts | 14 + apps/mobile/src/lib/composerImages.ts | 2 +- apps/mobile/vite.config.ts | 1 + .../AttachmentFilePreview.browser.test.tsx | 2 +- .../components/files/binaryPreview.worker.ts | 2 +- .../components/files/binaryPreviewProtocol.ts | 12 +- .../web/src/components/files/officeArchive.ts | 2 +- docs/user/composer.md | 4 +- packages/client-runtime/package.json | 9 + .../client-runtime/src/zip}/boundedZip.ts | 17 +- .../client-runtime/src/zip}/zipDirectory.ts | 0 pnpm-lock.yaml | 3 + 17 files changed, 1525 insertions(+), 114 deletions(-) create mode 100644 apps/mobile/src/features/files/AttachmentDocumentBody.tsx create mode 100644 apps/mobile/src/features/files/ZipArchiveView.test.tsx create mode 100644 apps/mobile/src/features/files/ZipArchiveView.tsx create mode 100644 apps/mobile/src/features/files/zipTree.ts rename {apps/web/src/components/files => packages/client-runtime/src/zip}/boundedZip.ts (86%) rename {apps/web/src/components/files => packages/client-runtime/src/zip}/zipDirectory.ts (100%) diff --git a/apps/mobile/src/features/files/AttachmentDocumentBody.tsx b/apps/mobile/src/features/files/AttachmentDocumentBody.tsx new file mode 100644 index 0000000000000..8cea0b4ce7998 --- /dev/null +++ b/apps/mobile/src/features/files/AttachmentDocumentBody.tsx @@ -0,0 +1,111 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { View } from "react-native"; + +import { EmptyState } from "../../components/ui/EmptyState"; +import type { useAttachmentDocument } from "../../lib/attachmentDocument"; +import { HtmlRenderWebView } from "../threads/HtmlRenderWebView"; +import { AudioFilePreview } from "./AudioFilePreview"; +import { DelimitedFilePreview } from "./DelimitedFilePreview"; +import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback"; +import { SourceFileSurface } from "./SourceFileSurface"; +import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; + +export function nativeViewerKind(kind: ReturnType["kind"]) { + if (kind === "image") return "image" as const; + if (kind === "pdf") return "pdf" as const; + if (kind === "video" || kind === "unsupported") return "document" as const; + return null; +} + +export function AttachmentDocumentBody(props: { + readonly document: Pick< + ReturnType, + | "kind" + | "activeMode" + | "uri" + | "content" + | "table" + | "error" + | "needsText" + | "retry" + | "revision" + >; + readonly name: string; + readonly environmentId: EnvironmentId | null; + readonly htmlRender: boolean; + readonly nativeViewer: "pending" | "open" | "unavailable" | null; + readonly nativeError: string | null; + readonly onOpenNative: () => void; +}) { + const { document } = props; + if (document.error) { + return ( + + + + ); + } + if (props.nativeViewer !== null && props.nativeViewer !== "unavailable") { + return ; + } + // ZIP text entries are read into memory and have no file URI. + if (document.needsText ? !document.content : !document.uri) { + return ; + } + if (document.needsText && document.content) { + const { content, table } = document; + return ( + + {content.truncated ? ( + + Preview limited to the first 1 MB. Save or share the file to read it in full. + + ) : null} + {table && document.activeMode === "table" ? ( + + ) : document.activeMode === "markdown" && props.environmentId ? ( + + ) : ( + + )} + + ); + } + if (!document.uri) return null; + if (document.kind === "audio") { + return ; + } + if (document.kind === "html") { + return props.htmlRender ? ( + + ) : ( + + ); + } + return ( + + + + ); +} diff --git a/apps/mobile/src/features/files/AttachmentFileScreen.tsx b/apps/mobile/src/features/files/AttachmentFileScreen.tsx index 493efc7cf0d33..a1d3978a57d45 100644 --- a/apps/mobile/src/features/files/AttachmentFileScreen.tsx +++ b/apps/mobile/src/features/files/AttachmentFileScreen.tsx @@ -3,8 +3,11 @@ import * as Dialog from "../../components/ui/Dialog"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { MenuAction } from "@react-native-menu/menu"; -import { EnvironmentId } from "@t3tools/contracts"; -import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + clampFileAttachmentUploadBytes, + formatAttachmentSize, +} from "@t3tools/client-runtime/state/attachments"; import { useRef, useState } from "react"; import { Platform, View } from "react-native"; @@ -12,10 +15,7 @@ import { AndroidHeaderIconButton, AndroidScreenHeader, } from "../../components/ui/AndroidScreenHeader"; -import { DelimitedFilePreview } from "./DelimitedFilePreview"; -import { AudioFilePreview } from "./AudioFilePreview"; import { Menu } from "../../components/ui/Menu"; -import { EmptyState } from "../../components/ui/EmptyState"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { useAttachmentDocument } from "../../lib/attachmentDocument"; import { nativeViewerErrorMessage } from "../../lib/attachmentDownload"; @@ -23,12 +23,12 @@ import { isFileBackedComposerAttachment } from "../../lib/composerImages"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts"; -import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback"; -import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { useEnvironmentServerConfig } from "../../state/entities"; +import { useRefreshAssetUrl } from "../../state/assets"; +import { scopedThreadKey } from "../../lib/scopedEntities"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import { SourceFileSurface } from "./SourceFileSurface"; -import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; -import { HtmlRenderWebView } from "../threads/HtmlRenderWebView"; +import { isZipFile, ZipArchiveView } from "./ZipArchiveView"; +import { AttachmentDocumentBody, nativeViewerKind } from "./AttachmentDocumentBody"; export type AttachmentFileRouteParams = { readonly environmentId?: string; @@ -43,92 +43,6 @@ export type AttachmentFileRouteParams = { type AttachmentFileScreenProps = StaticScreenProps; -function nativeViewerKind(kind: ReturnType["kind"]) { - if (kind === "image") return "image" as const; - if (kind === "pdf") return "pdf" as const; - if (kind === "video" || kind === "unsupported") return "document" as const; - return null; -} - -function AttachmentDocumentBody(props: { - readonly document: ReturnType; - readonly name: string; - readonly environmentId: EnvironmentId | null; - readonly htmlRender: boolean; - readonly nativeViewer: "pending" | "open" | "unavailable" | null; - readonly nativeError: string | null; - readonly onOpenNative: () => void; -}) { - const { document } = props; - if (document.error) { - return ( - - - - ); - } - if (props.nativeViewer !== null && props.nativeViewer !== "unavailable") { - return ; - } - if (!document.uri || (document.needsText && !document.content)) { - return ; - } - if (document.needsText && document.content) { - const { content, table } = document; - return ( - - {content.truncated ? ( - - Preview limited to the first 1 MB. Save or share the file to read it in full. - - ) : null} - {table && document.activeMode === "table" ? ( - - ) : document.activeMode === "markdown" && props.environmentId ? ( - - ) : ( - - )} - - ); - } - if (document.kind === "audio") { - return ; - } - if (document.kind === "html") { - return props.htmlRender ? ( - - ) : ( - - ); - } - return ( - - - - ); -} - export function AttachmentFileScreen(props: AttachmentFileScreenProps) { const navigation = useNavigation(); const { appearance, setCodeWordBreak } = useAppearancePreferences(); @@ -284,6 +198,36 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { menuActions.find(({ id }) => id === event.nativeEvent.event)?.onPress(); }; const subtitle = `${draftKey ? "Draft attachment" : "Attachment"}${sizeBytes > 0 ? ` ยท ${formatAttachmentSize(sizeBytes)}` : ""}`; + const refresh = useRefreshAssetUrl(environmentId, resource); + const capabilities = useEnvironmentServerConfig(environmentId)?.environment.capabilities; + + if (document.kind === "archive" && isZipFile(params) && !document.error) { + const threadDraftKey = + environmentId && params.threadId + ? scopedThreadKey(environmentId, ThreadId.make(params.threadId)) + : null; + return ( + void share()} + onRemove={draftKey ? removeFromDraft : null} + /> + ); + } return ( diff --git a/apps/mobile/src/features/files/ZipArchiveView.test.tsx b/apps/mobile/src/features/files/ZipArchiveView.test.tsx new file mode 100644 index 0000000000000..d26b7ecc4f911 --- /dev/null +++ b/apps/mobile/src/features/files/ZipArchiveView.test.tsx @@ -0,0 +1,315 @@ +import { AsyncResult } from "effect/reactivity"; +import { act, type ComponentProps, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, onTestFinished, vi } from "vite-plus/test"; +import type { ServerConfig } from "@t3tools/contracts"; + +import type { ZipTreeRow } from "./zipTree"; + +const files = vi.hoisted(() => new Map()); +const navigation = vi.hoisted(() => ({ remove: null as (() => void) | null })); +const server = vi.hoisted(() => ({ + capabilities: { repositoryIdentity: false } as ServerConfig["environment"]["capabilities"], + refresh: vi.fn(async () => "file:///first.zip"), +})); + +vi.mock("../../state/entities", () => ({ + useEnvironmentServerConfig: () => ({ environment: { capabilities: server.capabilities } }), +})); +vi.mock("../../state/assets", () => ({ + useRefreshAssetUrl: () => server.refresh, + useFreshAssetUrl: () => AsyncResult.success({ url: "file:///first.zip", authorizedAt: 0 }), +})); + +vi.mock("react-native", () => { + const Slot = (props: { readonly children?: ReactNode }) =>
{props.children}
; + return { + View: Slot, + Text: Slot, + ScrollView: Slot, + ActivityIndicator: () => null, + Platform: { OS: "ios" }, + Keyboard: { dismiss: vi.fn() }, + Pressable: (props: { + readonly children?: ReactNode; + readonly accessibilityLabel?: string; + readonly disabled?: boolean; + readonly onPress?: () => void; + }) => ( + + ), + TextInput: (props: { + readonly accessibilityLabel?: string; + readonly value: string; + readonly onChangeText: (value: string) => void; + }) => ( + props.onChangeText(event.target.value)} + /> + ), + }; +}); +vi.mock("@legendapp/list/react-native", () => ({ + LegendList: (props: { + readonly data: ZipTreeRow[]; + readonly ListHeaderComponent: ReactNode; + readonly ListEmptyComponent: ReactNode; + readonly keyExtractor: (row: ZipTreeRow) => string; + readonly renderItem: (input: { item: ZipTreeRow }) => ReactNode; + }) => ( +
+ {props.ListHeaderComponent} + {props.data.length === 0 ? props.ListEmptyComponent : null} + {props.data.map((item) => ( +
{props.renderItem({ item })}
+ ))} +
+ ), +})); +vi.mock("expo-file-system", () => ({ + Directory: class { + create() {} + }, + Paths: { document: { uri: "file:///documents" } }, + File: class { + constructor(private readonly uri: string) {} + get exists() { + return false; + } + get size() { + return files.get(this.uri)?.byteLength ?? null; + } + async arrayBuffer() { + const bytes = files.get(this.uri); + if (!bytes) throw new Error(`Missing ZIP fixture: ${this.uri}`); + return Uint8Array.from(bytes).buffer; + } + }, +})); +vi.mock("expo/fetch", () => ({ fetch: vi.fn() })); +vi.mock("expo-crypto", () => ({ randomUUID: vi.fn() })); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ canGoBack: () => false }), + usePreventRemove: (enabled: boolean, remove: () => void) => { + navigation.remove = enabled ? remove : null; + }, +})); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); +vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: () => null })); +vi.mock("../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../components/ui/Toolbar", () => { + const Slot = (props: { readonly children?: ReactNode }) =>
{props.children}
; + return { + Toolbar: Object.assign(Slot, { + Button: () => null, + Menu: Slot, + MenuAction: (props: { + readonly children?: ReactNode; + readonly disabled?: boolean; + readonly onPress?: () => void; + }) => ( + + ), + }), + }; +}); +vi.mock("../../components/ui/AndroidScreenHeader", () => ({ AndroidScreenHeader: () => null })); +vi.mock("../../components/ui/Menu", () => ({ Menu: () => null })); +vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("../../components/ui/Progress", () => ({ Progress: () => null })); +vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); +vi.mock("../../components/FilePreviewModal", () => ({ FilePreviewModal: () => null })); +vi.mock("../../lib/copyTextWithHaptic", () => ({})); +vi.mock("./SourceFileSurface", () => ({ + SourceFileSurface: (props: { readonly contents: string }) =>
{props.contents}
, +})); +vi.mock("./AudioFilePreview", () => ({ AudioFilePreview: () => null })); +vi.mock("./FileMarkdownPreview", () => ({ FileMarkdownPreview: () => null })); +vi.mock("./WorkspaceFileWebPreview", () => ({ WorkspaceFileWebPreview: () => null })); +vi.mock("../threads/HtmlRenderWebView", () => ({ HtmlRenderWebView: () => null })); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ + appearance: { codeWordBreak: false }, + themeVariables: { "--color-icon": "#000000" }, + setCodeWordBreak: () => {}, + }), +})); + +import { ZipArchiveView } from "./ZipArchiveView"; +import { AttachmentFileScreen } from "./AttachmentFileScreen"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { composerDraftsAtom } from "../../state/use-composer-drafts"; + +function setup() { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers({ toFake: ["setImmediate", "clearImmediate"] }); + files.clear(); + navigation.remove = null; + server.capabilities = { repositoryIdentity: false }; + server.refresh.mockClear(); + appAtomRegistry.set(composerDraftsAtom, {}); + files.set( + "file:///first.zip", + Uint8Array.from( + atob( + "UEsDBBQAAAAAAAAAIVyzyFShFgAAABYAAAAJAAAAZmlyc3QudHh0Rmlyc3QgYXJjaGl2ZSBjb250ZW50c1BLAQIUAxQAAAAAAAAAIVyzyFShFgAAABYAAAAJAAAAAAAAAAAAAACAAQAAAABmaXJzdC50eHRQSwUGAAAAAAEAAQA3AAAAPQAAAAAA", + ), + (character) => character.charCodeAt(0), + ), + ); + + const container = document.createElement("div"); + const root = createRoot(container); + onTestFinished(() => { + act(() => root.unmount()); + files.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + const props: ComponentProps = { + name: "archive.zip", + subtitle: "ZIP", + tintColor: "#000000", + sizeBytes: 0, + uri: "file:///first.zip", + isLocal: true, + refresh: async () => null, + environmentId: null, + draftKey: null, + maxAttachmentBytes: undefined, + onSave: () => {}, + onRemove: null, + }; + return { + container, + root, + props, + render: async (input = props) => { + await act(async () => root.render()); + }, + settle: async (assert: () => void) => { + await act(async () => vi.runAllTimersAsync()); + assert(); + }, + }; +} + +it("opens a ZIP chat attachment as a browsable archive on the attachment file screen", async () => { + const screen = setup(); + server.capabilities = { + repositoryIdentity: false, + attachmentUploads: true, + fileAttachments: { maxUploadBytes: 1024 }, + }; + const { fetch } = await import("expo/fetch"); + vi.mocked(fetch, { partial: true }).mockImplementation(async () => ({ + ok: true, + body: new Response(Uint8Array.from(files.get("file:///first.zip")!)).body, + })); + await act(async () => + screen.root.render( + , + ), + ); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + const add = Array.from(screen.container.querySelectorAll("button")).find( + (button) => button.textContent === "Add to chat", + ); + expect(add?.disabled).toBe(false); +}); + +it("keeps the ZIP filter and archive open when native Back closes a selected entry", async () => { + const screen = setup(); + await screen.render(); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + const filter = screen.container.querySelector( + '[aria-label="Filter files in ZIP"]', + )!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( + filter, + "first", + ); + filter.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + + expect(navigation.remove).not.toBeNull(); + await act(async () => navigation.remove!()); + + expect(screen.container.querySelector("pre")).toBeNull(); + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(); + expect( + screen.container.querySelector('[aria-label="Filter files in ZIP"]')?.value, + ).toBe("first"); +}); + +it("explains why an encrypted ZIP cannot be browsed", async () => { + const screen = setup(); + const bytes = files.get("file:///first.zip")!; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const directory = view.getUint32(bytes.length - 6, true); + view.setUint16(directory + 8, view.getUint16(directory + 8, true) | 1, true); + await screen.render(); + await screen.settle(() => expect(screen.container.textContent).toContain("Encrypted zip")); + expect(screen.container.querySelector('[aria-label="first.txt"]')).toBeNull(); +}); + +it("keeps the archive and selected entry when the parent re-renders with a new refresh", async () => { + const screen = setup(); + await screen.render(); + await screen.settle(() => + expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(), + ); + await act(async () => + screen.container.querySelector('[aria-label="first.txt"]')!.click(), + ); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + files.delete("file:///first.zip"); + + await screen.render({ ...screen.props, refresh: async () => null }); + await screen.settle(() => + expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"), + ); + expect(navigation.remove).not.toBeNull(); +}); diff --git a/apps/mobile/src/features/files/ZipArchiveView.tsx b/apps/mobile/src/features/files/ZipArchiveView.tsx new file mode 100644 index 0000000000000..537b9c736f6ee --- /dev/null +++ b/apps/mobile/src/features/files/ZipArchiveView.tsx @@ -0,0 +1,870 @@ +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { + ARCHIVE_ENTRY_MAX_BYTES, + ARCHIVE_MAX_BYTES, + boundedZip, +} from "@t3tools/client-runtime/zip/bounded-zip"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview"; +import { + decodeFilePreviewText, + filePreviewKind, + hostPreviewMimeTypeFromExtension, +} from "@t3tools/shared/filePreview"; +import { LegendList } from "@legendapp/list/react-native"; +import { fetch } from "expo/fetch"; +import { File } from "expo-file-system"; +import { useNavigation, usePreventRemove } from "@react-navigation/native"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { Keyboard, Platform, Pressable, ScrollView, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { PierreEntryIcon } from "../../components/PierreEntryIcon"; +import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { AppText as Text } from "../../components/ui/AppText"; +import { Button } from "../../components/ui/Button"; +import { EmptyState } from "../../components/ui/EmptyState"; +import { Progress } from "../../components/ui/Progress"; +import { Toolbar } from "../../components/ui/Toolbar"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import * as Dialog from "../../components/ui/Dialog"; +import { FilePreviewModal } from "../../components/FilePreviewModal"; +import { + nativeViewerErrorMessage, + shareGeneratedAttachment, + writeGeneratedAttachmentFile, +} from "../../lib/attachmentDownload"; +import { attachmentDocumentPresentation } from "../../lib/attachmentDocumentPresentation"; +import { + createComposerFileAttachment, + removePersistedComposerAttachmentFile, +} from "../../lib/composerImages"; +import { appendComposerDraftAttachments } from "../../state/use-composer-drafts"; +import { AttachmentDocumentBody, nativeViewerKind } from "./AttachmentDocumentBody"; +import { FilePreviewLoading } from "./FilePreviewFeedback"; +import { + buildZipTree, + flattenZipTree, + zipDownloadTimeLeft, + zipFolderLabel, + type ZipEntry, + type ZipTreeRow, +} from "./zipTree"; + +export function isZipFile(file: { readonly name: string; readonly mimeType: string }) { + const mime = file.mimeType.split(";", 1)[0]?.trim().toLowerCase(); + return ( + mime === "application/zip" || + mime === "application/x-zip-compressed" || + file.name.toLowerCase().endsWith(".zip") + ); +} + +interface ZipArchive { + readonly entries: ReadonlyArray; + readonly fileCount: number; +} + +interface ZipFailure { + readonly title: string; + readonly detail: string; + readonly retry: boolean; +} + +const SAVE_ZIP_HINT = "Save the ZIP to open it in another app."; +const TOO_LARGE: ZipFailure = { + title: "Too large to browse here", + detail: `Browsing works up to ${formatAttachmentSize(ARCHIVE_MAX_BYTES)}. ${SAVE_ZIP_HINT}`, + retry: false, +}; +const CANCELLED: ZipFailure = { + title: "Download cancelled", + detail: "The ZIP wasn't downloaded. Try again to list its files.", + retry: true, +}; +const NETWORK: ZipFailure = { + title: "Couldn't download this ZIP", + detail: "Check the connection to this environment and try again.", + retry: true, +}; + +class ZipFailureError extends Error { + constructor(readonly failure: ZipFailure) { + super(failure.title); + } +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error ? cause.message : fallback; +} + +async function readArchiveBody( + body: ReadableStream, + onProgress: (receivedBytes: number) => void, +) { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + chunks.push(value); + onProgress(size); + } + } finally { + void reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes.buffer; +} + +async function openArchive(bytes: ArrayBuffer): Promise { + let files: Awaited>; + try { + files = await boundedZip(bytes); + } catch (cause) { + throw new ZipFailureError({ + title: "Couldn't browse this ZIP", + detail: `${errorMessage(cause, "It may be incomplete or damaged.")} ${SAVE_ZIP_HINT}`, + retry: false, + }); + } + const entries = [...files].map(([path, file]): ZipEntry => { + const name = path.split("/").findLast(Boolean) ?? path; + const dot = name.lastIndexOf("."); + return { + path, + name, + isDirectory: file.directory, + sizeBytes: file.bytes.byteLength, + mimeType: + (dot > 0 ? hostPreviewMimeTypeFromExtension(name.slice(dot).toLowerCase()) : null) ?? + "application/octet-stream", + bytes: file.bytes, + }; + }); + return { entries, fileCount: entries.filter((entry) => !entry.isDirectory).length }; +} + +type ArchiveState = + | { readonly status: "loading"; readonly receivedBytes: number; readonly timeLeft: string | null } + | { readonly status: "ready"; readonly archive: ZipArchive } + | { readonly status: "failed"; readonly failure: ZipFailure }; + +function useZipArchive(input: { + readonly uri: string | null; + readonly isLocal: boolean; + readonly sizeBytes: number; + readonly refresh: () => Promise; +}) { + const [state, setState] = useState({ + status: "loading", + receivedBytes: 0, + timeLeft: null, + }); + const [revision, setRevision] = useState(0); + const controller = useRef(null); + const { uri, isLocal, sizeBytes } = input; + // The parent mints a new refresh callback on every render; it must not reload the archive. + const refresh = useEffectEvent(input.refresh); + // oxlint-disable-next-line t3code/react-effect-budget -- Load archive bytes and cancel the stream on cleanup. + useEffect(() => { + if (!uri) return; + const abort = new AbortController(); + controller.current = abort; + const startedAt = Date.now(); + // oxlint-disable-next-line react/set-state-in-effect -- A new archive load resets its progress. + setState({ status: "loading", receivedBytes: 0, timeLeft: null }); + void (async () => { + if (sizeBytes > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + if (isLocal) { + const file = new File(uri); + if ((file.size ?? 0) > ARCHIVE_MAX_BYTES) throw new ZipFailureError(TOO_LARGE); + return openArchive(await file.arrayBuffer()); + } + const url = (await refresh()) ?? uri; + const response = await fetch(url, { signal: abort.signal }); + if (!response.ok || !response.body) throw new ZipFailureError(NETWORK); + const bytes = await readArchiveBody(response.body, (receivedBytes) => + setState({ + status: "loading", + receivedBytes, + timeLeft: zipDownloadTimeLeft({ + receivedBytes, + totalBytes: sizeBytes, + elapsedMs: Date.now() - startedAt, + }), + }), + ); + abort.signal.throwIfAborted(); + return openArchive(bytes); + })() + .then((archive) => { + if (!abort.signal.aborted) setState({ status: "ready", archive }); + }) + .catch((cause: unknown) => { + if (!abort.signal.aborted) + setState({ + status: "failed", + failure: cause instanceof ZipFailureError ? cause.failure : NETWORK, + }); + }); + return () => abort.abort(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reload the archive. + }, [uri, isLocal, sizeBytes, revision]); + return { + state, + retry: () => setRevision((value) => value + 1), + cancel: () => { + controller.current?.abort(); + setState({ status: "failed", failure: CANCELLED }); + }, + }; +} + +function ZipTreeRowView(props: { + readonly row: ZipTreeRow; + readonly collapsed: boolean; + readonly onToggle: (path: string) => void; + readonly onOpen: (entry: ZipEntry) => void; +}) { + const { node, depth } = props.row; + const entry = node.entry; + const folder = entry ? null : zipFolderLabel(node.label); + return ( + (entry ? props.onOpen(entry) : props.onToggle(node.path))} + className="mx-2 h-11 flex-row items-center gap-1.5 rounded-lg pr-2.5 active:bg-subtle" + style={{ paddingLeft: 8 + depth * 18 }} + > + + {entry ? null : ( + + )} + + + + + + {folder ? ( + <> + {folder.prefix} + {folder.name} + + ) : ( + node.label + )} + + + {entry + ? formatAttachmentSize(entry.sizeBytes) + : props.collapsed + ? `${node.fileCount} ${node.fileCount === 1 ? "file" : "files"}` + : ""} + + + ); +} + +function ZipArchiveHeader(props: { + readonly name: string; + readonly subtitle: string; + readonly tintColor: string; + readonly onSave: () => void; + readonly onRemove: (() => void) | null; +}) { + const navigation = useNavigation(); + const isAndroid = Platform.OS === "android"; + return ( + <> + + {isAndroid ? ( + { + if (navigation.canGoBack()) navigation.goBack(); + }} + actions={[ + { accessibilityLabel: "Save ZIP", icon: "square.and.arrow.up", onPress: props.onSave }, + ...(props.onRemove + ? [ + { + accessibilityLabel: "Remove from draft", + icon: "trash" as const, + onPress: props.onRemove, + destructive: true, + overflowOnly: true, + }, + ] + : []), + ]} + /> + ) : ( + + {props.onRemove ? ( + + + Save ZIP + + + Remove from draft + + + ) : ( + + )} + + )} + + ); +} + +function ZipArchiveProgress(props: { + readonly receivedBytes: number; + readonly totalBytes: number; + readonly timeLeft: string | null; + readonly onCancel: () => void; +}) { + const { timeLeft } = props; + return ( + + + {formatAttachmentSize(props.receivedBytes)} + {props.totalBytes > 0 ? ( + + {` of ${formatAttachmentSize(props.totalBytes)}`} + + ) : null} + + 0 ? props.receivedBytes / props.totalBytes : undefined} + label="ZIP download progress" + /> + + {`Downloading the ZIP so its files can be listed.${timeLeft ? ` ${timeLeft} on this connection.` : ""}`} + +