| author | |
| committer | |
| log | 2aa907b1969237efdaced29612fb46ba51be7041 |
| tree | 27e014622a2df6a4ff85b03f5401977a06b1a0da |
| parent | 06336460c9988f29c71e839c4c9c840c4552e077 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>6 files changed, 219 insertions(+), 20 deletions(-)
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+27-4| ... | @@ -37,6 +37,7 @@ import { useThreadSelection } from "../../state/use-thread-selection"; | ... | @@ -37,6 +37,7 @@ import { useThreadSelection } from "../../state/use-thread-selection"; |
| 37 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; | 37 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 38 | import { useEnvironmentQuery } from "../../state/query"; | 38 | import { useEnvironmentQuery } from "../../state/query"; |
| 39 | import { projectEnvironment } from "../../state/projects"; | 39 | import { projectEnvironment } from "../../state/projects"; |
| 40 | import type { AssetUrlFailureReason } from "../../state/asset-url-state"; | ||
| 40 | import { | 41 | import { |
| 41 | useAdaptiveWorkspaceLayout, | 42 | useAdaptiveWorkspaceLayout, |
| 42 | useAdaptiveWorkspacePaneRole, | 43 | useAdaptiveWorkspacePaneRole, |
| ... | @@ -56,6 +57,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file"; | ... | @@ -56,6 +57,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| 56 | import { SourceFileSurface } from "./SourceFileSurface"; | 57 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 57 | import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; | 58 | import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; |
| 58 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; | 59 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; |
| 60 | import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError"; | ||
| 59 | import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; | 61 | import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; |
| 60 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; | 62 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; |
| 61 | import { | 63 | import { |
| ... | @@ -104,8 +106,10 @@ function defaultViewMode(path: string | null): FileViewMode { | ... | @@ -104,8 +106,10 @@ function defaultViewMode(path: string | null): FileViewMode { |
| 104 | 106 | ||
| 105 | function FileContent(props: { | 107 | function FileContent(props: { |
| 106 | readonly activeMode: FileViewMode; | 108 | readonly activeMode: FileViewMode; |
| 109 | readonly environmentId: EnvironmentId | null; | ||
| 107 | readonly previewUri: string | null; | 110 | readonly previewUri: string | null; |
| 108 | readonly previewUnavailable: boolean; | 111 | readonly previewFailure: AssetUrlFailureReason | null; |
| 112 | readonly onRetryPreview: () => void; | ||
| 109 | readonly videoSource: MediaVideoPreviewSource | null; | 113 | readonly videoSource: MediaVideoPreviewSource | null; |
| 110 | readonly mediaSource?: MediaActionsSource; | 114 | readonly mediaSource?: MediaActionsSource; |
| 111 | readonly resolveVideoUri: () => Promise<string | null>; | 115 | readonly resolveVideoUri: () => Promise<string | null>; |
| ... | @@ -121,8 +125,22 @@ function FileContent(props: { | ... | @@ -121,8 +125,22 @@ function FileContent(props: { |
| 121 | const isMarkdown = isMarkdownPreviewFile(props.relativePath); | 125 | const isMarkdown = isMarkdownPreviewFile(props.relativePath); |
| 122 | const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath); | 126 | const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath); |
| 123 | const isImageFile = isWorkspaceImagePreviewPath(props.relativePath); | 127 | const isImageFile = isWorkspaceImagePreviewPath(props.relativePath); |
| 128 | const isVideoFile = isVideoPreviewFile(props.relativePath); | ||
| 129 | // Only the surfaces that wait on a signed asset URL can be blocked by one. | ||
| 130 | const needsAssetUrl = | ||
| 131 | isVideoFile || (props.activeMode === "preview" && (isImageFile || isBrowserFile)); | ||
| 124 | 132 | ||
| 125 | if (isVideoPreviewFile(props.relativePath)) { | 133 | if (needsAssetUrl && props.previewFailure !== null) { |
| 134 | return ( | ||
| 135 | <WorkspaceFilePreviewError | ||
| 136 | environmentId={props.environmentId} | ||
| 137 | reason={props.previewFailure} | ||
| 138 | onRetry={props.onRetryPreview} | ||
| 139 | /> | ||
| 140 | ); | ||
| 141 | } | ||
| 142 | |||
| 143 | if (isVideoFile) { | ||
| 126 | return ( | 144 | return ( |
| 127 | <WorkspaceFileVideoPreview | 145 | <WorkspaceFileVideoPreview |
| 128 | name={basename(props.relativePath)} | 146 | name={basename(props.relativePath)} |
| ... | @@ -130,7 +148,6 @@ function FileContent(props: { | ... | @@ -130,7 +148,6 @@ function FileContent(props: { |
| 130 | uri={props.previewUri} | 148 | uri={props.previewUri} |
| 131 | source={props.videoSource} | 149 | source={props.videoSource} |
| 132 | resolvePlaybackUri={props.resolveVideoUri} | 150 | resolvePlaybackUri={props.resolveVideoUri} |
| 133 | unavailable={props.previewUnavailable} | ||
| 134 | /> | 151 | /> |
| 135 | ); | 152 | ); |
| 136 | } | 153 | } |
| ... | @@ -588,6 +605,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -588,6 +605,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 588 | assetPreviewUri === null || previewRevision === 0 | 605 | assetPreviewUri === null || previewRevision === 0 |
| 589 | ? assetPreviewUri | 606 | ? assetPreviewUri |
| 590 | : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; | 607 | : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; |
| 608 | // Remounting the preview after a re-mint is what makes a failed asset URL retryable. | ||
| 609 | const handleRetryPreview = () => { | ||
| 610 | void assetPreview.refresh().finally(() => setPreviewRevision((current) => current + 1)); | ||
| 611 | }; | ||
| 591 | const needsFileContents = | 612 | const needsFileContents = |
| 592 | relativePath !== null && | 613 | relativePath !== null && |
| 593 | !isVideoFile && | 614 | !isVideoFile && |
| ... | @@ -889,8 +910,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -889,8 +910,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 889 | <FileContent | 910 | <FileContent |
| 890 | key={previewKey} | 911 | key={previewKey} |
| 891 | activeMode={resolvedActiveMode} | 912 | activeMode={resolvedActiveMode} |
| 913 | environmentId={environmentId} | ||
| 892 | previewUri={previewUri} | 914 | previewUri={previewUri} |
| 893 | previewUnavailable={assetPreview._tag === "Failure"} | 915 | previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null} |
| 916 | onRetryPreview={handleRetryPreview} | ||
| 894 | videoSource={videoSource} | 917 | videoSource={videoSource} |
| 895 | mediaSource={mediaSource} | 918 | mediaSource={mediaSource} |
| 896 | resolveVideoUri={assetPreview.refresh} | 919 | resolveVideoUri={assetPreview.refresh} |
apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { useCallback } from "react"; | ||
| 3 | import { View } from "react-native"; | ||
| 4 | |||
| 5 | import { EmptyState } from "../../components/EmptyState"; | ||
| 6 | import { environmentCatalog } from "../../connection/catalog"; | ||
| 7 | import type { AssetUrlFailureReason } from "../../state/asset-url-state"; | ||
| 8 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 9 | import { useEnvironmentPresentation } from "../../state/presentation"; | ||
| 10 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; | ||
| 11 | |||
| 12 | /** | ||
| 13 | * Terminal state for a preview whose signed asset URL will not arrive. A dead | ||
| 14 | * environment reuses the same notice the terminal and review sheets show, so the | ||
| 15 | * user gets one recognizable way back online. | ||
| 16 | */ | ||
| 17 | export function WorkspaceFilePreviewError(props: { | ||
| 18 | readonly environmentId: EnvironmentId | null; | ||
| 19 | readonly reason: AssetUrlFailureReason; | ||
| 20 | readonly onRetry: () => void; | ||
| 21 | }) { | ||
| 22 | const { environmentId, onRetry } = props; | ||
| 23 | const environment = useEnvironmentPresentation(environmentId); | ||
| 24 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); | ||
| 25 | const retryConnection = useCallback(() => { | ||
| 26 | if (environmentId !== null) void retryEnvironment(environmentId); | ||
| 27 | onRetry(); | ||
| 28 | }, [environmentId, onRetry, retryEnvironment]); | ||
| 29 | |||
| 30 | if (props.reason === "disconnected") { | ||
| 31 | return ( | ||
| 32 | <View className="flex-1 bg-sheet"> | ||
| 33 | <EnvironmentConnectionNotice | ||
| 34 | environmentLabel={environment.presentation?.entry.target.label ?? "Environment"} | ||
| 35 | connection={ | ||
| 36 | environment.presentation?.connection ?? { | ||
| 37 | phase: "available", | ||
| 38 | error: null, | ||
| 39 | traceId: null, | ||
| 40 | } | ||
| 41 | } | ||
| 42 | resourceName="preview" | ||
| 43 | onRetry={retryConnection} | ||
| 44 | /> | ||
| 45 | </View> | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | |||
| 49 | return ( | ||
| 50 | <View className="flex-1 items-center justify-center bg-sheet px-6"> | ||
| 51 | <EmptyState | ||
| 52 | title="Preview unavailable" | ||
| 53 | detail="This file may be missing, unsupported, or unavailable on this environment." | ||
| 54 | actionLabel="Try again" | ||
| 55 | onAction={props.onRetry} | ||
| 56 | /> | ||
| 57 | </View> | ||
| 58 | ); | ||
| 59 | } | ||
apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx-13| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { View } from "react-native"; | 1 | import { View } from "react-native"; |
| 2 | 2 | ||
| 3 | import { EmptyState } from "../../components/EmptyState"; | ||
| 4 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; | 3 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; |
| 5 | import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; | 4 | import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; |
| 6 | 5 | ||
| ... | @@ -11,21 +10,9 @@ export function WorkspaceFileVideoPreview(props: { | ... | @@ -11,21 +10,9 @@ export function WorkspaceFileVideoPreview(props: { |
| 11 | readonly uri: string | null; | 10 | readonly uri: string | null; |
| 12 | readonly source: MediaVideoPreviewSource | null; | 11 | readonly source: MediaVideoPreviewSource | null; |
| 13 | readonly resolvePlaybackUri: () => Promise<string | null>; | 12 | readonly resolvePlaybackUri: () => Promise<string | null>; |
| 14 | readonly unavailable: boolean; | ||
| 15 | }) { | 13 | }) { |
| 16 | const uri = props.uri; | 14 | const uri = props.uri; |
| 17 | 15 | ||
| 18 | if (props.unavailable) { | ||
| 19 | return ( | ||
| 20 | <View className="flex-1 items-center justify-center bg-sheet px-6"> | ||
| 21 | <EmptyState | ||
| 22 | title="Video unavailable" | ||
| 23 | detail="This file may be missing, unsupported, or unavailable on this environment." | ||
| 24 | /> | ||
| 25 | </View> | ||
| 26 | ); | ||
| 27 | } | ||
| 28 | |||
| 29 | return ( | 16 | return ( |
| 30 | <View className="flex-1 items-center justify-center bg-sheet p-4"> | 17 | <View className="flex-1 items-center justify-center bg-sheet p-4"> |
| 31 | <MediaVideoPlayer | 18 | <MediaVideoPlayer |
apps/mobile/src/state/asset-url-state.test.ts created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { deriveAssetUrlState } from "./asset-url-state"; | ||
| 4 | |||
| 5 | const SUCCESS = { _tag: "Success" as const, url: "https://environment.example/api/assets/abc" }; | ||
| 6 | |||
| 7 | describe("deriveAssetUrlState", () => { | ||
| 8 | it("passes a resolved URL through on a live environment", () => { | ||
| 9 | expect(deriveAssetUrlState({ connectionPhase: "connected", shared: SUCCESS })).toEqual(SUCCESS); | ||
| 10 | }); | ||
| 11 | |||
| 12 | it("waits while the query is pending and the environment is still reachable", () => { | ||
| 13 | for (const connectionPhase of ["available", "connecting", "connected"] as const) { | ||
| 14 | expect(deriveAssetUrlState({ connectionPhase, shared: { _tag: "Loading" } })).toEqual({ | ||
| 15 | _tag: "Loading", | ||
| 16 | }); | ||
| 17 | } | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("stops waiting once the environment is offline, retrying, or in error", () => { | ||
| 21 | for (const connectionPhase of ["offline", "reconnecting", "error"] as const) { | ||
| 22 | expect(deriveAssetUrlState({ connectionPhase, shared: { _tag: "Loading" } })).toEqual({ | ||
| 23 | _tag: "Failure", | ||
| 24 | reason: "disconnected", | ||
| 25 | }); | ||
| 26 | } | ||
| 27 | }); | ||
| 28 | |||
| 29 | // A dead environment fails the URL query itself, so the query outcome alone | ||
| 30 | // cannot tell a missing file from a missing connection. | ||
| 31 | it("reports disconnected when the query failed while the environment is down", () => { | ||
| 32 | for (const connectionPhase of ["available", "offline", "reconnecting", "error"] as const) { | ||
| 33 | expect(deriveAssetUrlState({ connectionPhase, shared: { _tag: "Failure" } })).toEqual({ | ||
| 34 | _tag: "Failure", | ||
| 35 | reason: "disconnected", | ||
| 36 | }); | ||
| 37 | } | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("does not hand out a resolved URL for a disconnected environment", () => { | ||
| 41 | for (const connectionPhase of ["offline", "reconnecting", "error"] as const) { | ||
| 42 | expect(deriveAssetUrlState({ connectionPhase, shared: SUCCESS })).toEqual({ | ||
| 43 | _tag: "Failure", | ||
| 44 | reason: "disconnected", | ||
| 45 | }); | ||
| 46 | } | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("reports a failed query on a connected environment", () => { | ||
| 50 | expect( | ||
| 51 | deriveAssetUrlState({ connectionPhase: "connected", shared: { _tag: "Failure" } }), | ||
| 52 | ).toEqual({ _tag: "Failure", reason: "failed" }); | ||
| 53 | }); | ||
| 54 | }); | ||
apps/mobile/src/state/asset-url-state.ts created+47| ... | @@ -0,0 +1,47 @@ | ||
| 1 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | ||
| 2 | import type { AssetUrlState as SharedAssetUrlState } from "@t3tools/client-runtime/state/assets"; | ||
| 3 | |||
| 4 | export type AssetUrlFailureReason = "disconnected" | "failed"; | ||
| 5 | |||
| 6 | /** The shared state plus a reason on failure, so previews can offer the right retry. */ | ||
| 7 | export type AssetUrlState = | ||
| 8 | | { readonly _tag: "Loading" } | ||
| 9 | | { readonly _tag: "Failure"; readonly reason: AssetUrlFailureReason } | ||
| 10 | | Extract<SharedAssetUrlState, { readonly _tag: "Success" }>; | ||
| 11 | |||
| 12 | /** | ||
| 13 | * Folds the shared asset URL state with the environment connection phase. A | ||
| 14 | * dead environment wins over everything else: even a resolved URL is | ||
| 15 | * unreachable there, and a failed query is caused by the outage, not the file. | ||
| 16 | */ | ||
| 17 | export function deriveAssetUrlState(input: { | ||
| 18 | readonly connectionPhase: EnvironmentConnectionPhase; | ||
| 19 | readonly shared: SharedAssetUrlState; | ||
| 20 | }): AssetUrlState { | ||
| 21 | if ( | ||
| 22 | input.connectionPhase === "offline" || | ||
| 23 | input.connectionPhase === "reconnecting" || | ||
| 24 | input.connectionPhase === "error" | ||
| 25 | ) { | ||
| 26 | return { _tag: "Failure", reason: "disconnected" }; | ||
| 27 | } | ||
| 28 | if (input.shared._tag === "Success") { | ||
| 29 | return input.shared; | ||
| 30 | } | ||
| 31 | switch (input.connectionPhase) { | ||
| 32 | // "available" is the idle, not yet dialled state. A pending query there is | ||
| 33 | // still on its way, but the query atom fails at once while idle, so a | ||
| 34 | // failure means the environment is not connected rather than the file is | ||
| 35 | // missing. | ||
| 36 | case "available": | ||
| 37 | return input.shared._tag === "Failure" | ||
| 38 | ? { _tag: "Failure", reason: "disconnected" } | ||
| 39 | : { _tag: "Loading" }; | ||
| 40 | case "connecting": | ||
| 41 | return { _tag: "Loading" }; | ||
| 42 | case "connected": | ||
| 43 | return input.shared._tag === "Failure" | ||
| 44 | ? { _tag: "Failure", reason: "failed" } | ||
| 45 | : { _tag: "Loading" }; | ||
| 46 | } | ||
| 47 | } | ||
apps/mobile/src/state/assets.ts+32-3| ... | @@ -1,35 +1,64 @@ | ... | @@ -1,35 +1,64 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 2 | import { |
| 3 | type AssetUrlState, | 3 | type EnvironmentConnectionPhase, |
| 4 | presentConnectionState, | ||
| 5 | } from "@t3tools/client-runtime/connection"; | ||
| 6 | import { | ||
| 4 | assetUrlStateFromResult, | 7 | assetUrlStateFromResult, |
| 5 | createAssetEnvironmentAtoms, | 8 | createAssetEnvironmentAtoms, |
| 6 | EMPTY_ASSET_URL_ATOM, | 9 | EMPTY_ASSET_URL_ATOM, |
| 7 | } from "@t3tools/client-runtime/state/assets"; | 10 | } from "@t3tools/client-runtime/state/assets"; |
| 8 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | 11 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 9 | import { useCallback } from "react"; | 14 | import { useCallback } from "react"; |
| 10 | 15 | ||
| 16 | import { environmentCatalog } from "../connection/catalog"; | ||
| 11 | import { connectionAtomRuntime } from "../connection/runtime"; | 17 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 18 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; | ||
| 12 | import { usePreparedConnection } from "./session"; | 19 | import { usePreparedConnection } from "./session"; |
| 13 | import { useAtomQueryRunner } from "./use-atom-query-runner"; | 20 | import { useAtomQueryRunner } from "./use-atom-query-runner"; |
| 14 | 21 | ||
| 15 | export type { AssetUrlState } from "@t3tools/client-runtime/state/assets"; | 22 | export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; |
| 16 | 23 | ||
| 17 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); | 24 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); |
| 18 | 25 | ||
| 26 | const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( | ||
| 27 | Atom.withLabel("mobile-asset-connection-state:empty"), | ||
| 28 | ); | ||
| 29 | |||
| 30 | function useConnectionPhase(environmentId: EnvironmentId | null): EnvironmentConnectionPhase { | ||
| 31 | const state = useAtomValue( | ||
| 32 | environmentId === null | ||
| 33 | ? EMPTY_CONNECTION_STATE_ATOM | ||
| 34 | : environmentCatalog.stateAtom(environmentId), | ||
| 35 | ); | ||
| 36 | const value = Option.getOrNull(AsyncResult.value(state)); | ||
| 37 | return value === null ? "available" : presentConnectionState(value).phase; | ||
| 38 | } | ||
| 39 | |||
| 19 | export function useAssetUrlState( | 40 | export function useAssetUrlState( |
| 20 | environmentId: EnvironmentId | null, | 41 | environmentId: EnvironmentId | null, |
| 21 | resource: AssetResource | null, | 42 | resource: AssetResource | null, |
| 22 | ): AssetUrlState { | 43 | ): AssetUrlState { |
| 23 | const preparedConnection = usePreparedConnection(environmentId); | 44 | const preparedConnection = usePreparedConnection(environmentId); |
| 45 | const connectionPhase = useConnectionPhase(environmentId); | ||
| 24 | const result = useAtomValue( | 46 | const result = useAtomValue( |
| 25 | environmentId === null || resource === null | 47 | environmentId === null || resource === null |
| 26 | ? EMPTY_ASSET_URL_ATOM | 48 | ? EMPTY_ASSET_URL_ATOM |
| 27 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), | 49 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 28 | ); | 50 | ); |
| 29 | return assetUrlStateFromResult( | 51 | const shared = assetUrlStateFromResult( |
| 30 | result, | 52 | result, |
| 31 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, | 53 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, |
| 32 | ); | 54 | ); |
| 55 | return deriveAssetUrlState({ | ||
| 56 | connectionPhase, | ||
| 57 | // A failure left over from an outage is re-queried as soon as the | ||
| 58 | // connection returns. While that re-query is in flight it is not a verdict | ||
| 59 | // on the file, so it reads as loading rather than a false "unavailable". | ||
| 60 | shared: shared._tag === "Failure" && result.waiting ? { _tag: "Loading" } : shared, | ||
| 61 | }); | ||
| 33 | } | 62 | } |
| 34 | 63 | ||
| 35 | export function useAssetUrl( | 64 | export function useAssetUrl( |