| 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 +6,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages"; |
| 6 | import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages"; | 6 | import { composerAttachmentInlineUri, isFileBackedComposerAttachment } from "../lib/composerImages"; |
| 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 8 | import { downloadAndShareAttachment } from "../lib/attachmentDownload"; | 8 | import { downloadAndShareAttachment } from "../lib/attachmentDownload"; |
| 9 | import { assetImageSource } from "../lib/assetImageSource"; | ||
| 9 | import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; | 10 | import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; |
| 10 | import { AppText as Text } from "./AppText"; | 11 | import { AppText as Text } from "./AppText"; |
| 11 | import { FilePreviewModal } from "./FilePreviewModal"; | 12 | import { FilePreviewModal } from "./FilePreviewModal"; |
| ... | @@ -98,7 +99,11 @@ export function ComposerContextAttachment(props: { | ... | @@ -98,7 +99,11 @@ export function ComposerContextAttachment(props: { |
| 98 | onPress={() => setPreviewOpen(true)} | 99 | onPress={() => setPreviewOpen(true)} |
| 99 | > | 100 | > |
| 100 | <Image | 101 | <Image |
| 101 | source={{ uri }} | 102 | source={ |
| 103 | remoteEnvironmentId === null | ||
| 104 | ? { uri } | ||
| 105 | : assetImageSource(remoteEnvironmentId, resource, uri) | ||
| 106 | } | ||
| 102 | style={{ width: "100%", height: 280 }} | 107 | style={{ width: "100%", height: 280 }} |
| 103 | contentFit="contain" | 108 | contentFit="contain" |
| 104 | accessibilityLabel={record.name} | 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,6 +5,7 @@ import { Pressable, ScrollView, View } from "react-native"; |
| 5 | import { AppText as Text } from "../../components/AppText"; | 5 | import { AppText as Text } from "../../components/AppText"; |
| 6 | import { SymbolView } from "../../components/AppSymbol"; | 6 | import { SymbolView } from "../../components/AppSymbol"; |
| 7 | import { useAssetUrl } from "../../state/assets"; | 7 | import { useAssetUrl } from "../../state/assets"; |
| 8 | import { assetImageSource } from "../../lib/assetImageSource"; | ||
| 8 | 9 | ||
| 9 | /** | 10 | /** |
| 10 | * The composer is shared, so editing a queued message needs a visible way back | 11 | * The composer is shared, so editing a queued message needs a visible way back |
| ... | @@ -75,24 +76,21 @@ function QueuedEditAttachmentChip(props: { | ... | @@ -75,24 +76,21 @@ function QueuedEditAttachmentChip(props: { |
| 75 | }) { | 76 | }) { |
| 76 | const { attachment } = props; | 77 | const { attachment } = props; |
| 77 | const isImage = attachment.mimeType.startsWith("image/"); | 78 | const isImage = attachment.mimeType.startsWith("image/"); |
| 78 | const url = useAssetUrl( | 79 | const resource = { |
| 79 | props.environmentId, | 80 | _tag: "attachment", |
| 80 | isImage | 81 | attachmentId: attachment.id, |
| 81 | ? { | 82 | fileName: attachment.name, |
| 82 | _tag: "attachment", | 83 | mimeType: attachment.mimeType, |
| 83 | attachmentId: attachment.id, | 84 | disposition: "inline", |
| 84 | fileName: attachment.name, | 85 | } as const; |
| 85 | mimeType: attachment.mimeType, | 86 | const url = useAssetUrl(props.environmentId, isImage ? resource : null); |
| 86 | disposition: "inline", | ||
| 87 | } | ||
| 88 | : null, | ||
| 89 | ); | ||
| 90 | 87 | ||
| 91 | return ( | 88 | return ( |
| 92 | <View className="max-w-[180px] flex-row items-center gap-2 rounded-xl border border-border bg-card py-1 pl-1 pr-2"> | 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 | {isImage && url !== null ? ( | 90 | {isImage && url !== null ? ( |
| 94 | <Image | 91 | <Image |
| 95 | source={{ uri: url }} | 92 | source={assetImageSource(props.environmentId, resource, url)} |
| 93 | cachePolicy="memory-disk" | ||
| 96 | contentFit="cover" | 94 | contentFit="cover" |
| 97 | style={{ width: 24, height: 24, borderRadius: 6 }} | 95 | style={{ width: 24, height: 24, borderRadius: 6 }} |
| 98 | accessibilityIgnoresInvertColors | 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,9 +6,9 @@ import { describe, expect, it, vi } from "vite-plus/test"; |
| 6 | vi.mock("react-native", () => ({ | 6 | vi.mock("react-native", () => ({ |
| 7 | View: "div", | 7 | View: "div", |
| 8 | Pressable: ({ children }: { children: ReactNode }) => <button>{children}</button>, | 8 | Pressable: ({ children }: { children: ReactNode }) => <button>{children}</button>, |
| 9 | Image: "img", | ||
| 10 | Linking: { openURL: vi.fn() }, | 9 | Linking: { openURL: vi.fn() }, |
| 11 | })); | 10 | })); |
| 11 | vi.mock("expo-image", () => ({ Image: "img" })); | ||
| 12 | vi.mock("../../components/AppText", () => ({ AppText: "span" })); | 12 | vi.mock("../../components/AppText", () => ({ AppText: "span" })); |
| 13 | vi.mock("../../state/assets", () => ({ useAssetUrl: () => null })); | 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,19 +3,19 @@ import type { |
| 3 | UserInputAttachmentAnswerPayload, | 3 | UserInputAttachmentAnswerPayload, |
| 4 | UserInputAttachments, | 4 | UserInputAttachments, |
| 5 | } from "@t3tools/contracts"; | 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 | import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input"; | 8 | import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input"; |
| 8 | import { AppText as Text } from "../../components/AppText"; | 9 | import { AppText as Text } from "../../components/AppText"; |
| 10 | import { assetImageSource } from "../../lib/assetImageSource"; | ||
| 9 | import { useAssetUrl } from "../../state/assets"; | 11 | import { useAssetUrl } from "../../state/assets"; |
| 10 | 12 | ||
| 11 | function AnswerFile(props: { | 13 | function AnswerFile(props: { |
| 12 | environmentId: EnvironmentId; | 14 | environmentId: EnvironmentId; |
| 13 | attachment: UserInputAttachments[string][number]; | 15 | attachment: UserInputAttachments[string][number]; |
| 14 | }) { | 16 | }) { |
| 15 | const url = useAssetUrl(props.environmentId, { | 17 | const resource = { _tag: "attachment", attachmentId: props.attachment.id } as const; |
| 16 | _tag: "attachment", | 18 | const url = useAssetUrl(props.environmentId, resource); |
| 17 | attachmentId: props.attachment.id, | ||
| 18 | }); | ||
| 19 | return ( | 19 | return ( |
| 20 | <Pressable | 20 | <Pressable |
| 21 | accessibilityRole="link" | 21 | accessibilityRole="link" |
| ... | @@ -27,7 +27,12 @@ function AnswerFile(props: { | ... | @@ -27,7 +27,12 @@ function AnswerFile(props: { |
| 27 | className="gap-1" | 27 | className="gap-1" |
| 28 | > | 28 | > |
| 29 | {props.attachment.type === "image" && url ? ( | 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 | ) : null} | 36 | ) : null} |
| 32 | <Text className="text-sm text-foreground underline">{props.attachment.name}</Text> | 37 | <Text className="text-sm text-foreground underline">{props.attachment.name}</Text> |
| 33 | </Pressable> | 38 | </Pressable> |
apps/mobile/src/features/threads/ThreadFeed.tsx+9-4| ... | @@ -94,7 +94,10 @@ import { | ... | @@ -94,7 +94,10 @@ import { |
| 94 | useWindowDimensions, | 94 | useWindowDimensions, |
| 95 | View, | 95 | View, |
| 96 | } from "react-native"; | 96 | } from "react-native"; |
| 97 | import { Image as ExpoImage } from "expo-image"; | ||
| 98 | import { useResolveClassNames } from "uniwind"; | ||
| 97 | import { useFilePreviewGallery, FilePreviewGallery } from "../../components/FilePreviewGallery"; | 99 | import { useFilePreviewGallery, FilePreviewGallery } from "../../components/FilePreviewGallery"; |
| 100 | import { assetImageSource } from "../../lib/assetImageSource"; | ||
| 98 | import { composerImageGallery } from "../../lib/composerImageGallery"; | 101 | import { composerImageGallery } from "../../lib/composerImageGallery"; |
| 99 | import { messageImageGallery } from "./messageImageGallery"; | 102 | import { messageImageGallery } from "./messageImageGallery"; |
| 100 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | 103 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; |
| ... | @@ -457,6 +460,7 @@ function MessageAttachmentImage(props: { | ... | @@ -457,6 +460,7 @@ function MessageAttachmentImage(props: { |
| 457 | const uri = useAssetUrl(props.environmentId, resource); | 460 | const uri = useAssetUrl(props.environmentId, resource); |
| 458 | const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource); | 461 | const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource); |
| 459 | const retriedImage = useRef(false); | 462 | const retriedImage = useRef(false); |
| 463 | const imageStyle = useResolveClassNames(props.className); | ||
| 460 | 464 | ||
| 461 | if (uri === null) { | 465 | if (uri === null) { |
| 462 | return ( | 466 | return ( |
| ... | @@ -490,10 +494,11 @@ function MessageAttachmentImage(props: { | ... | @@ -490,10 +494,11 @@ function MessageAttachmentImage(props: { |
| 490 | ) | 494 | ) |
| 491 | } | 495 | } |
| 492 | > | 496 | > |
| 493 | <Image | 497 | <ExpoImage |
| 494 | source={{ uri }} | 498 | source={assetImageSource(props.environmentId, resource, uri)} |
| 495 | className={props.className} | 499 | cachePolicy="memory-disk" |
| 496 | resizeMode="cover" | 500 | style={imageStyle} |
| 501 | contentFit="cover" | ||
| 497 | onLoad={() => { | 502 | onLoad={() => { |
| 498 | retriedImage.current = false; | 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,6 +23,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 23 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 23 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 24 | import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; | 24 | import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 25 | import { useAssetUrl } from "../../state/assets"; | 25 | import { useAssetUrl } from "../../state/assets"; |
| 26 | import { assetImageSource } from "../../lib/assetImageSource"; | ||
| 26 | import { beginQueuedRunEdit, useQueuedRunEdit } from "../../state/queued-run-edit"; | 27 | import { beginQueuedRunEdit, useQueuedRunEdit } from "../../state/queued-run-edit"; |
| 27 | import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; | 28 | import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; |
| 28 | import { useAtomCommand } from "../../state/use-atom-command"; | 29 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | @@ -563,19 +564,21 @@ function QueueAttachmentThumbnail(props: { | ... | @@ -563,19 +564,21 @@ function QueueAttachmentThumbnail(props: { |
| 563 | readonly environmentId: EnvironmentId; | 564 | readonly environmentId: EnvironmentId; |
| 564 | readonly attachment: ChatAttachment; | 565 | readonly attachment: ChatAttachment; |
| 565 | }) { | 566 | }) { |
| 566 | const url = useAssetUrl(props.environmentId, { | 567 | const resource = { |
| 567 | _tag: "attachment", | 568 | _tag: "attachment", |
| 568 | attachmentId: props.attachment.id, | 569 | attachmentId: props.attachment.id, |
| 569 | fileName: props.attachment.name, | 570 | fileName: props.attachment.name, |
| 570 | mimeType: props.attachment.mimeType, | 571 | mimeType: props.attachment.mimeType, |
| 571 | disposition: "inline", | 572 | disposition: "inline", |
| 572 | }); | 573 | } as const; |
| 574 | const url = useAssetUrl(props.environmentId, resource); | ||
| 573 | if (url === null) { | 575 | if (url === null) { |
| 574 | return <View className="h-6 w-6 rounded bg-subtle" />; | 576 | return <View className="h-6 w-6 rounded bg-subtle" />; |
| 575 | } | 577 | } |
| 576 | return ( | 578 | return ( |
| 577 | <Image | 579 | <Image |
| 578 | source={{ uri: url }} | 580 | source={assetImageSource(props.environmentId, resource, url)} |
| 581 | cachePolicy="memory-disk" | ||
| 579 | contentFit="cover" | 582 | contentFit="cover" |
| 580 | style={{ width: 24, height: 24, borderRadius: 4 }} | 583 | style={{ width: 24, height: 24, borderRadius: 4 }} |
| 581 | accessibilityIgnoresInvertColors | 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,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| 9 | attachmentFileExtension, | 9 | attachmentFileExtension, |
| 10 | chatAttachmentStableKey, | ||
| 10 | createAttachmentId, | 11 | createAttachmentId, |
| 11 | createDeterministicAttachmentId, | 12 | createDeterministicAttachmentId, |
| 12 | createPendingAttachmentId, | 13 | createPendingAttachmentId, |
| ... | @@ -30,6 +31,27 @@ describe("attachmentStore", () => { | ... | @@ -30,6 +31,27 @@ describe("attachmentStore", () => { |
| 30 | expect(first && parseThreadSegmentFromAttachmentId(first)).toBe("thread-1"); | 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 | it("keeps deterministic ids distinct when sanitized thread segments collide", () => { | 55 | it("keeps deterministic ids distinct when sanitized thread segments collide", () => { |
| 34 | const dotted = createDeterministicAttachmentId("thread.a", "message-1:0"); | 56 | const dotted = createDeterministicAttachmentId("thread.a", "message-1:0"); |
| 35 | const dashed = createDeterministicAttachmentId("thread-a", "message-1:0"); | 57 | const dashed = createDeterministicAttachmentId("thread-a", "message-1:0"); |
apps/server/src/attachmentStore.ts+15| ... | @@ -103,6 +103,21 @@ export function createDeterministicAttachmentId( | ... | @@ -103,6 +103,21 @@ export function createDeterministicAttachmentId( |
| 103 | return `${threadSegment}-${uuid}`; | 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 | export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { | 121 | export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { |
| 107 | const normalizedId = normalizeAttachmentRelativePath(attachmentId); | 122 | const normalizedId = normalizeAttachmentRelativePath(attachmentId); |
| 108 | if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { | 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,7 +185,11 @@ import * as DeviceService from "./device/DeviceService.ts"; |
| 185 | import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; | 185 | import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; |
| 186 | import * as PreviewManager from "./preview/Manager.ts"; | 186 | import * as PreviewManager from "./preview/Manager.ts"; |
| 187 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; | 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 | import { parseBase64DataUrl } from "./imageMime.ts"; | 193 | import { parseBase64DataUrl } from "./imageMime.ts"; |
| 190 | import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; | 194 | import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; |
| 191 | import * as PortScanner from "./preview/PortScanner.ts"; | 195 | import * as PortScanner from "./preview/PortScanner.ts"; |
| ... | @@ -398,7 +402,10 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun | ... | @@ -398,7 +402,10 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun |
| 398 | message: `Attachment ${attachment.name} size does not match its payload.`, | 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 | if (rawId === null) { | 409 | if (rawId === null) { |
| 403 | return yield* new PersistChatAttachmentsError({ | 410 | return yield* new PersistChatAttachmentsError({ |
| 404 | message: "Could not allocate an attachment identifier.", | 411 | message: "Could not allocate an attachment identifier.", |