Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ export default defineConfig({
"**/observer-feed-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/composer-image-draw.spec.ts",
"**/video-attachment.spec.ts",
"**/spoiler.spec.ts",
"**/composer-tooltip-dismiss.spec.ts",
"**/mentions.spec.ts",
"**/relay-reconnect.spec.ts",
"**/relay-reconnect-affordance.spec.ts",
Expand Down
19 changes: 19 additions & 0 deletions desktop/src-tauri/src/commands/media_download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,25 @@ pub async fn download_file(
save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await
}

/// Fetch relay media bytes for the composer image editor.
///
/// The editor composites the image onto a canvas and needs pixel access.
/// Handing the webview raw bytes over IPC (which it wraps in a same-origin
/// `blob:` URL) keeps the canvas un-tainted without involving CORS — and
/// therefore without any media-proxy header or origin-gate changes.
///
/// Same SSRF validation, size cap, and content policy as the download
/// commands above.
#[tauri::command]
pub async fn fetch_media_bytes(url: String, state: State<'_, AppState>) -> Result<Vec<u8>, String> {
let relay_base = relay_api_base_url_with_override(&state);
validate_download_url(&url, &relay_base)?;

let bytes = fetch_blob_bytes(&url, &state).await?;
detect_and_validate_mime(&bytes)?;
Ok(bytes)
}

/// Fetch blob bytes from a (pre-validated) relay media URL through the app's
/// HTTP client, enforcing the download size cap. The caller is responsible for
/// validating the URL origin and for any content-type checks on the result.
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ pub fn run() {
upload_media_bytes,
download_image,
download_file,
fetch_media_bytes,
list_relay_members,
get_my_relay_membership,
add_relay_member,
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import * as React from "react";

import type { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { ComposerAttachments } from "@/features/messages/ui/ComposerAttachments";

type ComposerMedia = Pick<
ReturnType<typeof useMediaUpload>,
| "isUploading"
| "cancelUpload"
| "originalUrlByUrl"
| "pendingImeta"
| "removeAttachment"
| "revertAttachment"
| "setUploadState"
| "uploadEditedAttachment"
| "uploadState"
| "uploadingCount"
| "uploadingPreviews"
Expand All @@ -20,6 +25,13 @@ type ForumComposerMediaStatusProps = {
export function ForumComposerMediaStatus({
media,
}: ForumComposerMediaStatusProps) {
const handleEditSave = React.useCallback(
async (url: string, bytes: Uint8Array) => {
await media.uploadEditedAttachment(url, bytes);
},
[media.uploadEditedAttachment],
);

return (
<>
{media.uploadState.status === "error" ? (
Expand All @@ -41,7 +53,10 @@ export function ForumComposerMediaStatus({
attachments={media.pendingImeta}
isUploading={media.isUploading}
onCancelUpload={media.cancelUpload}
onEditSave={handleEditSave}
onRemove={media.removeAttachment}
onRevert={media.revertAttachment}
originalUrlByUrl={media.originalUrlByUrl}
uploadingCount={media.uploadingCount}
uploadingPreviews={media.uploadingPreviews}
/>
Expand Down
53 changes: 53 additions & 0 deletions desktop/src/features/messages/lib/useAttachmentEditing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as React from "react";

import type { MediaUploadController } from "./useMediaUpload";

type UseAttachmentEditingArgs = {
revertAttachment: MediaUploadController["revertAttachment"];
/** Spoiler-set updater; membership follows the attachment across URL swaps. */
setSpoileredAttachmentUrls: React.Dispatch<React.SetStateAction<Set<string>>>;
uploadEditedAttachment: MediaUploadController["uploadEditedAttachment"];
};

/**
* Composer-side glue for the attachment drawing editor: uploads annotated
* bytes as a replacement / reverts to the pre-edit original, migrating
* spoiler membership from the replaced URL to its replacement so an edited
* spoilered image stays spoilered.
*/
export function useAttachmentEditing({
revertAttachment,
setSpoileredAttachmentUrls,
uploadEditedAttachment,
}: UseAttachmentEditingArgs) {
const migrateSpoileredUrl = React.useCallback(
(fromUrl: string, toUrl: string) => {
setSpoileredAttachmentUrls((current) => {
if (!current.has(fromUrl)) return current;
const next = new Set(current);
next.delete(fromUrl);
next.add(toUrl);
return next;
});
},
[setSpoileredAttachmentUrls],
);

const handleAttachmentEditSave = React.useCallback(
async (url: string, bytes: Uint8Array) => {
const descriptor = await uploadEditedAttachment(url, bytes);
if (descriptor) migrateSpoileredUrl(url, descriptor.url);
},
[migrateSpoileredUrl, uploadEditedAttachment],
);

const handleAttachmentRevert = React.useCallback(
(url: string) => {
const original = revertAttachment(url);
if (original) migrateSpoileredUrl(url, original.url);
},
[migrateSpoileredUrl, revertAttachment],
);

return { handleAttachmentEditSave, handleAttachmentRevert };
}
118 changes: 118 additions & 0 deletions desktop/src/features/messages/lib/useMediaUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,44 @@ export function useMediaUpload() {
const pendingImetaRef = React.useRef(pendingImeta);
pendingImetaRef.current = pendingImeta;

/**
* Pre-edit originals of annotated attachments, keyed by the annotated
* attachment's URL. Powers "revert to original" in the composer lightbox.
* In-memory only — cleared implicitly when the attachment leaves the
* composer (send, remove, draft switch).
*/
const [originalsByUrl, setOriginalsByUrl] = React.useState<
Map<string, BlobDescriptor>
>(() => new Map());
const originalsByUrlRef = React.useRef(originalsByUrl);
originalsByUrlRef.current = originalsByUrl;

/** Annotated URL → original URL (derived; handy for stable list keys). */
const originalUrlByUrl = React.useMemo(() => {
const map = new Map<string, string>();
for (const [url, original] of originalsByUrl) map.set(url, original.url);
return map;
}, [originalsByUrl]);

// Prune originals whose annotated attachment is no longer pending —
// covers remove, cancel, send-clear, and draft restore in one place.
React.useEffect(() => {
setOriginalsByUrl((prev) => {
if (prev.size === 0) return prev;
const liveUrls = new Set(pendingImeta.map((d) => d.url));
let changed = false;
const next = new Map<string, BlobDescriptor>();
for (const [url, original] of prev) {
if (liveUrls.has(url)) {
next.set(url, original);
} else {
changed = true;
}
}
return changed ? next : prev;
});
}, [pendingImeta]);

/** Monotonic slot counter — ensures each batch gets unique indices even
* before React flushes the state update. */
const nextSlotRef = React.useRef(0);
Expand Down Expand Up @@ -511,6 +549,80 @@ export function useMediaUpload() {
[isUploadCanceled, onUploaded, onUploadError, reserveUploadingPreview],
);

/**
* Upload an annotated replacement for an existing image attachment and
* swap it into the same slot (attachment order is preserved). The pre-edit
* descriptor is remembered in `originalsByUrl` so the edit can be reverted;
* chained edits keep the earliest original as the single revert point.
*
* Returns the new descriptor, or null if `oldUrl` is no longer pending.
* Rejects on upload failure (after surfacing the standard error banner) so
* callers can keep their editing UI open.
*/
const uploadEditedAttachment = React.useCallback(
async (
oldUrl: string,
bytes: Uint8Array,
): Promise<BlobDescriptor | null> => {
const oldDescriptor = pendingImetaRef.current.find(
(d) => d.url === oldUrl,
);
if (!oldDescriptor) return null;

// The annotated output is always PNG — swap the extension accordingly.
const stem = (oldDescriptor.filename ?? "image").replace(/\.[^.]+$/, "");
const filename = `${stem}.png`;

const previewId = reserveUploadingPreview();
setUploadingCount((c) => c + 1);
try {
const descriptor = await uploadMediaBytes(
[...bytes],
filename,
uploadProgressId(previewId),
);
if (isUploadCanceled(previewId)) return null;
finishUpload(previewId);
setImetaSlots((prev) =>
prev.map((d) => (d?.url === oldUrl ? descriptor : d)),
);
setOriginalsByUrl((prev) => {
const next = new Map(prev);
// Re-editing an annotated image keeps the earliest original.
const original = prev.get(oldUrl) ?? oldDescriptor;
next.delete(oldUrl);
next.set(descriptor.url, original);
return next;
});
return descriptor;
} catch (err) {
onUploadError(err, previewId);
throw err;
}
},
[finishUpload, isUploadCanceled, onUploadError, reserveUploadingPreview],
);

/**
* Swap an annotated attachment back to its pre-edit original (same slot)
* and forget the stored original. Returns the restored descriptor, or null
* if the URL has no recorded original.
*/
const revertAttachment = React.useCallback(
(url: string): BlobDescriptor | null => {
const original = originalsByUrlRef.current.get(url);
if (!original) return null;
setImetaSlots((prev) => prev.map((d) => (d?.url === url ? original : d)));
setOriginalsByUrl((prev) => {
const next = new Map(prev);
next.delete(url);
return next;
});
return original;
},
[],
);

const removeAttachment = React.useCallback((url: string) => {
setImetaSlots((prev) => prev.map((d) => (d?.url === url ? null : d)));
}, []);
Expand Down Expand Up @@ -541,11 +653,14 @@ export function useMediaUpload() {
handlePaste,
isDragOver,
isUploading,
originalUrlByUrl,
pendingImeta,
pendingImetaRef,
removeAttachment,
revertAttachment,
setPendingImeta,
setUploadState,
uploadEditedAttachment,
uploadFile,
uploadingCount,
uploadingPreviews,
Expand All @@ -561,9 +676,12 @@ export function useMediaUpload() {
handlePaste,
isDragOver,
isUploading,
originalUrlByUrl,
pendingImeta,
removeAttachment,
revertAttachment,
setPendingImeta,
uploadEditedAttachment,
uploadFile,
uploadingCount,
uploadingPreviews,
Expand Down
Loading
Loading