Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ 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",
"**/mentions.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