| author | |
| committer | |
| log | 1af138668b8bcf5c289382dde1ccaecdd79614b8 |
| tree | b4b68209e57751d4ff61d4f8da71b8277c4ec489 |
| parent | 346247bf1a461d200209b3f33f2bb7f148987b1f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: PR Batch Tester <agent@local.test>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>11 files changed, 168 insertions(+), 30 deletions(-)
apps/mobile/src/components/ComposerContextAttachment.tsx+6-1| ... | ... | @@ -6,6 +6,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages"; |
| 6 | 6 | import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages"; |
| 7 | 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 8 | 8 | import { downloadAndShareAttachment } from "../lib/attachmentDownload"; |
| 9 | import { assetImageSource } from "../lib/assetImageSource"; | |
| 9 | 10 | import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; |
| 10 | 11 | import { AppText as Text } from "./AppText"; |
| 11 | 12 | import { FilePreviewModal } from "./FilePreviewModal"; |
| ... | ... | @@ -98,7 +99,11 @@ export function ComposerContextAttachment(props: { |
| 98 | 99 | onPress={() => setPreviewOpen(true)} |
| 99 | 100 | > |
| 100 | 101 | <Image |
| 101 | source={{ uri }} | |
| 102 | source={ | |
| 103 | remoteEnvironmentId === null | |
| 104 | ? { uri } | |
| 105 | : assetImageSource(remoteEnvironmentId, resource, uri) | |
| 106 | } | |
| 102 | 107 | style={{ width: "100%", height: 280 }} |
| 103 | 108 | contentFit="contain" |
| 104 | 109 | accessibilityLabel={record.name} |
apps/mobile/src/features/threads/ComposerQueuedEdit.tsx+11-13| ... | ... | @@ -5,6 +5,7 @@ import { Pressable, ScrollView, View } from "react-native"; |
| 5 | 5 | import { AppText as Text } from "../../components/AppText"; |
| 6 | 6 | import { SymbolView } from "../../components/AppSymbol"; |
| 7 | 7 | import { useAssetUrl } from "../../state/assets"; |
| 8 | import { assetImageSource } from "../../lib/assetImageSource"; | |
| 8 | 9 | |
| 9 | 10 | /** |
| 10 | 11 | * The composer is shared, so editing a queued message needs a visible way back |
| ... | ... | @@ -75,24 +76,21 @@ function QueuedEditAttachmentChip(props: { |
| 75 | 76 | }) { |
| 76 | 77 | const { attachment } = props; |
| 77 | 78 | const isImage = attachment.mimeType.startsWith("image/"); |
| 78 | const url = useAssetUrl( | |
| 79 | props.environmentId, | |
| 80 | isImage | |
| 81 | ? { | |
| 82 | _tag: "attachment", | |
| 83 | attachmentId: attachment.id, | |
| 84 | fileName: attachment.name, | |
| 85 | mimeType: attachment.mimeType, | |
| 86 | disposition: "inline", | |
| 87 | } | |
| 88 | : null, | |
| 89 | ); | |
| 79 | const resource = { | |
| 80 | _tag: "attachment", | |
| 81 | attachmentId: attachment.id, | |
| 82 | fileName: attachment.name, | |
| 83 | mimeType: attachment.mimeType, | |
| 84 | disposition: "inline", | |
| 85 | } as const; | |
| 86 | const url = useAssetUrl(props.environmentId, isImage ? resource : null); | |
| 90 | 87 | |
| 91 | 88 | return ( |
| 92 | 89 | <View className="max-w-[180px] flex-row items-center gap-2 rounded-xl border border-border bg-card py-1 pl-1 pr-2"> |
| 93 | 90 | {isImage && url !== null ? ( |
| 94 | 91 | <Image |
| 95 | source={{ uri: url }} | |
| 92 | source={assetImageSource(props.environmentId, resource, url)} | |
| 93 | cachePolicy="memory-disk" | |
| 96 | 94 | contentFit="cover" |
| 97 | 95 | style={{ width: 24, height: 24, borderRadius: 6 }} |
| 98 | 96 | accessibilityIgnoresInvertColors |
apps/mobile/src/features/threads/QuestionAnswerHistory.test.tsx+1-1| ... | ... | @@ -6,9 +6,9 @@ import { describe, expect, it, vi } from "vite-plus/test"; |
| 6 | 6 | vi.mock("react-native", () => ({ |
| 7 | 7 | View: "div", |
| 8 | 8 | Pressable: ({ children }: { children: ReactNode }) => <button>{children}</button>, |
| 9 | Image: "img", | |
| 10 | 9 | Linking: { openURL: vi.fn() }, |
| 11 | 10 | })); |
| 11 | vi.mock("expo-image", () => ({ Image: "img" })); | |
| 12 | 12 | vi.mock("../../components/AppText", () => ({ AppText: "span" })); |
| 13 | 13 | vi.mock("../../state/assets", () => ({ useAssetUrl: () => null })); |
| 14 | 14 |
apps/mobile/src/features/threads/QuestionAnswerHistory.tsx+11-6| ... | ... | @@ -3,19 +3,19 @@ import type { |
| 3 | 3 | UserInputAttachmentAnswerPayload, |
| 4 | 4 | UserInputAttachments, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | import { Image, Linking, Pressable, View } from "react-native"; | |
| 6 | import { Image } from "expo-image"; | |
| 7 | import { Linking, Pressable, View } from "react-native"; | |
| 7 | 8 | import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input"; |
| 8 | 9 | import { AppText as Text } from "../../components/AppText"; |
| 10 | import { assetImageSource } from "../../lib/assetImageSource"; | |
| 9 | 11 | import { useAssetUrl } from "../../state/assets"; |
| 10 | 12 | |
| 11 | 13 | function AnswerFile(props: { |
| 12 | 14 | environmentId: EnvironmentId; |
| 13 | 15 | attachment: UserInputAttachments[string][number]; |
| 14 | 16 | }) { |
| 15 | const url = useAssetUrl(props.environmentId, { | |
| 16 | _tag: "attachment", | |
| 17 | attachmentId: props.attachment.id, | |
| 18 | }); | |
| 17 | const resource = { _tag: "attachment", attachmentId: props.attachment.id } as const; | |
| 18 | const url = useAssetUrl(props.environmentId, resource); | |
| 19 | 19 | return ( |
| 20 | 20 | <Pressable |
| 21 | 21 | accessibilityRole="link" |
| ... | ... | @@ -27,7 +27,12 @@ function AnswerFile(props: { |
| 27 | 27 | className="gap-1" |
| 28 | 28 | > |
| 29 | 29 | {props.attachment.type === "image" && url ? ( |
| 30 | <Image source={{ uri: url }} style={{ width: 160, height: 96 }} resizeMode="contain" /> | |
| 30 | <Image | |
| 31 | source={assetImageSource(props.environmentId, resource, url)} | |
| 32 | cachePolicy="memory-disk" | |
| 33 | style={{ width: 160, height: 96 }} | |
| 34 | contentFit="contain" | |
| 35 | /> | |
| 31 | 36 | ) : null} |
| 32 | 37 | <Text className="text-sm text-foreground underline">{props.attachment.name}</Text> |
| 33 | 38 | </Pressable> |
apps/mobile/src/features/threads/ThreadFeed.tsx+9-4| ... | ... | @@ -94,7 +94,10 @@ import { |
| 94 | 94 | useWindowDimensions, |
| 95 | 95 | View, |
| 96 | 96 | } from "react-native"; |
| 97 | import { Image as ExpoImage } from "expo-image"; | |
| 98 | import { useResolveClassNames } from "uniwind"; | |
| 97 | 99 | import { useFilePreviewGallery, FilePreviewGallery } from "../../components/FilePreviewGallery"; |
| 100 | import { assetImageSource } from "../../lib/assetImageSource"; | |
| 98 | 101 | import { composerImageGallery } from "../../lib/composerImageGallery"; |
| 99 | 102 | import { messageImageGallery } from "./messageImageGallery"; |
| 100 | 103 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| ... | ... | @@ -457,6 +460,7 @@ function MessageAttachmentImage(props: { |
| 457 | 460 | const uri = useAssetUrl(props.environmentId, resource); |
| 458 | 461 | const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource); |
| 459 | 462 | const retriedImage = useRef(false); |
| 463 | const imageStyle = useResolveClassNames(props.className); | |
| 460 | 464 | |
| 461 | 465 | if (uri === null) { |
| 462 | 466 | return ( |
| ... | ... | @@ -490,10 +494,11 @@ function MessageAttachmentImage(props: { |
| 490 | 494 | ) |
| 491 | 495 | } |
| 492 | 496 | > |
| 493 | <Image | |
| 494 | source={{ uri }} | |
| 495 | className={props.className} | |
| 496 | resizeMode="cover" | |
| 497 | <ExpoImage | |
| 498 | source={assetImageSource(props.environmentId, resource, uri)} | |
| 499 | cachePolicy="memory-disk" | |
| 500 | style={imageStyle} | |
| 501 | contentFit="cover" | |
| 497 | 502 | onLoad={() => { |
| 498 | 503 | retriedImage.current = false; |
| 499 | 504 | }} |
apps/mobile/src/features/threads/ThreadQueueControl.tsx+6-3| ... | ... | @@ -23,6 +23,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 23 | 23 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 24 | 24 | import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 25 | 25 | import { useAssetUrl } from "../../state/assets"; |
| 26 | import { assetImageSource } from "../../lib/assetImageSource"; | |
| 26 | 27 | import { beginQueuedRunEdit, useQueuedRunEdit } from "../../state/queued-run-edit"; |
| 27 | 28 | import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; |
| 28 | 29 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | ... | @@ -563,19 +564,21 @@ function QueueAttachmentThumbnail(props: { |
| 563 | 564 | readonly environmentId: EnvironmentId; |
| 564 | 565 | readonly attachment: ChatAttachment; |
| 565 | 566 | }) { |
| 566 | const url = useAssetUrl(props.environmentId, { | |
| 567 | const resource = { | |
| 567 | 568 | _tag: "attachment", |
| 568 | 569 | attachmentId: props.attachment.id, |
| 569 | 570 | fileName: props.attachment.name, |
| 570 | 571 | mimeType: props.attachment.mimeType, |
| 571 | 572 | disposition: "inline", |
| 572 | }); | |
| 573 | } as const; | |
| 574 | const url = useAssetUrl(props.environmentId, resource); | |
| 573 | 575 | if (url === null) { |
| 574 | 576 | return <View className="h-6 w-6 rounded bg-subtle" />; |
| 575 | 577 | } |
| 576 | 578 | return ( |
| 577 | 579 | <Image |
| 578 | source={{ uri: url }} | |
| 580 | source={assetImageSource(props.environmentId, resource, url)} | |
| 581 | cachePolicy="memory-disk" | |
| 579 | 582 | contentFit="cover" |
| 580 | 583 | style={{ width: 24, height: 24, borderRadius: 4 }} |
| 581 | 584 | accessibilityIgnoresInvertColors |
apps/mobile/src/lib/assetImageSource.test.ts created+58| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { assetImageSource } from "./assetImageSource"; | |
| 5 | ||
| 6 | const environmentId = EnvironmentId.make("environment-1"); | |
| 7 | ||
| 8 | describe("assetImageSource", () => { | |
| 9 | it("caches an attachment under one key across re-signed URLs", () => { | |
| 10 | const resource = { _tag: "attachment", attachmentId: "thread-1-photo.jpg" } as const; | |
| 11 | const first = assetImageSource( | |
| 12 | environmentId, | |
| 13 | resource, | |
| 14 | "https://host/api/assets/token-a/p.jpg", | |
| 15 | ); | |
| 16 | const second = assetImageSource( | |
| 17 | environmentId, | |
| 18 | resource, | |
| 19 | "https://host/api/assets/token-b/p.jpg", | |
| 20 | ); | |
| 21 | ||
| 22 | expect(first.cacheKey).toBeDefined(); | |
| 23 | expect(second).toStrictEqual({ | |
| 24 | uri: "https://host/api/assets/token-b/p.jpg", | |
| 25 | cacheKey: first.cacheKey, | |
| 26 | }); | |
| 27 | expect( | |
| 28 | assetImageSource(EnvironmentId.make("environment-2"), resource, "https://other/p.jpg") | |
| 29 | .cacheKey, | |
| 30 | ).not.toBe(first.cacheKey); | |
| 31 | }); | |
| 32 | ||
| 33 | it("keeps ids that contain colons from sharing a key", () => { | |
| 34 | const uri = "https://host/api/assets/token/p.jpg"; | |
| 35 | const left = assetImageSource( | |
| 36 | EnvironmentId.make("a"), | |
| 37 | { _tag: "attachment", attachmentId: "b:c" }, | |
| 38 | uri, | |
| 39 | ); | |
| 40 | const right = assetImageSource( | |
| 41 | EnvironmentId.make("a:b"), | |
| 42 | { _tag: "attachment", attachmentId: "c" }, | |
| 43 | uri, | |
| 44 | ); | |
| 45 | ||
| 46 | expect(left.cacheKey).not.toBe(right.cacheKey); | |
| 47 | }); | |
| 48 | ||
| 49 | it("keeps workspace files keyed by URL because they can change on disk", () => { | |
| 50 | const source = assetImageSource( | |
| 51 | environmentId, | |
| 52 | { _tag: "draft-workspace-file", cwd: "/repo", path: "logo.png" }, | |
| 53 | "https://host/api/assets/token-a/logo.png", | |
| 54 | ); | |
| 55 | ||
| 56 | expect(source).toStrictEqual({ uri: "https://host/api/assets/token-a/logo.png" }); | |
| 57 | }); | |
| 58 | }); |
apps/mobile/src/lib/assetImageSource.ts created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import type { ImageSource } from "expo-image"; | |
| 3 | ||
| 4 | /** | |
| 5 | * expo-image source for a signed asset URL. Every mint signs a new URL, and a | |
| 6 | * reconnect mints again, so a URL-keyed cache misses each time. Attachments | |
| 7 | * never change once stored, so they cache under their id instead. | |
| 8 | */ | |
| 9 | export function assetImageSource( | |
| 10 | environmentId: EnvironmentId, | |
| 11 | resource: AssetResource, | |
| 12 | uri: string, | |
| 13 | ): ImageSource { | |
| 14 | return resource._tag === "attachment" | |
| 15 | ? { | |
| 16 | uri, | |
| 17 | cacheKey: `t3-attachment:${JSON.stringify([environmentId, resource.attachmentId])}`, | |
| 18 | } | |
| 19 | : { uri }; | |
| 20 | } |
apps/server/src/attachmentStore.test.ts+22| ... | ... | @@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 7 | 7 | |
| 8 | 8 | import { |
| 9 | 9 | attachmentFileExtension, |
| 10 | chatAttachmentStableKey, | |
| 10 | 11 | createAttachmentId, |
| 11 | 12 | createDeterministicAttachmentId, |
| 12 | 13 | createPendingAttachmentId, |
| ... | ... | @@ -30,6 +31,27 @@ describe("attachmentStore", () => { |
| 30 | 31 | expect(first && parseThreadSegmentFromAttachmentId(first)).toBe("thread-1"); |
| 31 | 32 | }); |
| 32 | 33 | |
| 34 | it("gives a queued edit's new image its own id instead of an earlier image's", () => { | |
| 35 | const imageA = new Uint8Array([1, 2, 3]); | |
| 36 | const imageB = new Uint8Array([4, 5, 6]); | |
| 37 | const sent = createDeterministicAttachmentId( | |
| 38 | "thread-1", | |
| 39 | chatAttachmentStableKey("message-1", 0, imageA), | |
| 40 | ); | |
| 41 | const retried = createDeterministicAttachmentId( | |
| 42 | "thread-1", | |
| 43 | chatAttachmentStableKey("message-1", 0, imageA), | |
| 44 | ); | |
| 45 | // Editing the queued message keeps its id, and the new upload is first in its batch. | |
| 46 | const edited = createDeterministicAttachmentId( | |
| 47 | "thread-1", | |
| 48 | chatAttachmentStableKey("message-1", 0, imageB), | |
| 49 | ); | |
| 50 | ||
| 51 | expect(retried).toBe(sent); | |
| 52 | expect(edited).not.toBe(sent); | |
| 53 | }); | |
| 54 | ||
| 33 | 55 | it("keeps deterministic ids distinct when sanitized thread segments collide", () => { |
| 34 | 56 | const dotted = createDeterministicAttachmentId("thread.a", "message-1:0"); |
| 35 | 57 | const dashed = createDeterministicAttachmentId("thread-a", "message-1:0"); |
apps/server/src/attachmentStore.ts+15| ... | ... | @@ -103,6 +103,21 @@ export function createDeterministicAttachmentId( |
| 103 | 103 | return `${threadSegment}-${uuid}`; |
| 104 | 104 | } |
| 105 | 105 | |
| 106 | /** | |
| 107 | * Stable key for an inline chat attachment. A retry resends the same bytes and | |
| 108 | * keeps its id. Editing a queued message reuses the message id, so the bytes | |
| 109 | * also stop a new image from taking an earlier image's id and file. Clients | |
| 110 | * cache attachment images by id, which relies on ids never changing bytes. | |
| 111 | */ | |
| 112 | export function chatAttachmentStableKey( | |
| 113 | messageId: string, | |
| 114 | index: number, | |
| 115 | bytes: Uint8Array, | |
| 116 | ): string { | |
| 117 | const digest = NodeCrypto.createHash("sha256").update(bytes).digest("hex"); | |
| 118 | return `${messageId}:${index}:${digest}`; | |
| 119 | } | |
| 120 | ||
| 106 | 121 | export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { |
| 107 | 122 | const normalizedId = normalizeAttachmentRelativePath(attachmentId); |
| 108 | 123 | if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { |
apps/server/src/ws.ts+9-2| ... | ... | @@ -185,7 +185,11 @@ import * as DeviceService from "./device/DeviceService.ts"; |
| 185 | 185 | import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; |
| 186 | 186 | import * as PreviewManager from "./preview/Manager.ts"; |
| 187 | 187 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; |
| 188 | import { attachmentRelativePath, createDeterministicAttachmentId } from "./attachmentStore.ts"; | |
| 188 | import { | |
| 189 | attachmentRelativePath, | |
| 190 | chatAttachmentStableKey, | |
| 191 | createDeterministicAttachmentId, | |
| 192 | } from "./attachmentStore.ts"; | |
| 189 | 193 | import { parseBase64DataUrl } from "./imageMime.ts"; |
| 190 | 194 | import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; |
| 191 | 195 | import * as PortScanner from "./preview/PortScanner.ts"; |
| ... | ... | @@ -398,7 +402,10 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun |
| 398 | 402 | message: `Attachment ${attachment.name} size does not match its payload.`, |
| 399 | 403 | }); |
| 400 | 404 | } |
| 401 | const rawId = createDeterministicAttachmentId(input.threadId, `${input.messageId}:${index}`); | |
| 405 | const rawId = createDeterministicAttachmentId( | |
| 406 | input.threadId, | |
| 407 | chatAttachmentStableKey(input.messageId, index, bytes), | |
| 408 | ); | |
| 402 | 409 | if (rawId === null) { |
| 403 | 410 | return yield* new PersistChatAttachmentsError({ |
| 404 | 411 | message: "Could not allocate an attachment identifier.", |