Skip to content
Open
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
8 changes: 6 additions & 2 deletions desktop/src/features/agents/ui/AgentCreationPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export function AgentCreationPreview({
clearError: clearUploadError,
openPicker: openUploadPicker,
uploadFile: uploadAvatarFile,
uploadUrl: uploadAvatarUrl,
handleFileChange: handleAvatarUploadFileChange,
} = useAvatarUpload({
onUploadSuccess: (url) => {
Expand Down Expand Up @@ -183,8 +184,11 @@ export function AgentCreationPreview({
return;
}
clearUploadError();
onSelectAvatar(nextUrl);
setIsAvatarMenuOpen(false);
void uploadAvatarUrl(nextUrl).then((uploaded) => {
if (!uploaded) return;
setAvatarUrlDraft("");
setIsAvatarMenuOpen(false);
});
}

function applyEmojiAvatar(emoji: string, color = selectedColor) {
Expand Down
92 changes: 92 additions & 0 deletions desktop/src/features/profile/avatarUrlImport.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import test from "node:test";

import { importAvatarUrl, parseImportUrl } from "./avatarUrlImport.ts";

const HASH = "b".repeat(64);

function response(body, headers = {}) {
return new Response(body, { headers, status: 200 });
}

test("parseImportUrl rejects non-http URL schemes", () => {
assert.throws(
() => parseImportUrl("file:///tmp/avatar.png"),
/HTTP\(S\) image URL/,
);
assert.throws(
() => parseImportUrl("data:image/png,abc"),
/HTTP\(S\) image URL/,
);
});

test("importAvatarUrl fetches client-side bytes and uploads the artefact", async () => {
const fetched = [];
const uploaded = [];
const descriptor = await importAvatarUrl("https://cdn.example/avatar.png", {
relayOrigin: "https://relay.example",
fetchFn: async (url) => {
fetched.push(String(url));
return response(
new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
{
"content-type": "image/png",
"content-length": "3",
},
);
},
uploadFn: async (data, filename) => {
uploaded.push({ data, filename });
return {
sha256: "hash",
size: data.length,
type: "image/png",
uploaded: Date.now(),
url: `https://relay.example/media/${HASH}.png`,
};
},
});

assert.deepEqual(fetched, ["https://cdn.example/avatar.png"]);
assert.deepEqual(uploaded, [{ data: [1, 2, 3], filename: "avatar.png" }]);
assert.equal(descriptor.url, `https://relay.example/media/${HASH}.png`);
});

test("importAvatarUrl rejects non-image responses before upload", async () => {
let uploaded = false;
await assert.rejects(
() =>
importAvatarUrl("https://cdn.example/not-image.txt", {
relayOrigin: "https://relay.example",
fetchFn: async () =>
response("hello", {
"content-type": "text/plain",
"content-length": "5",
}),
uploadFn: async () => {
uploaded = true;
throw new Error("unreachable");
},
}),
/PNG, JPG, GIF, or WebP/,
);
assert.equal(uploaded, false);
});

test("importAvatarUrl refuses oversized images", async () => {
await assert.rejects(
() =>
importAvatarUrl("https://cdn.example/avatar.png", {
relayOrigin: "https://relay.example",
fetchFn: async () =>
response(new Blob(["tiny"], { type: "image/png" }), {
"content-type": "image/png",
"content-length": String(11 * 1024 * 1024),
}),
uploadFn: async () => {
throw new Error("unreachable");
},
}),
/too large/,
);
});
133 changes: 133 additions & 0 deletions desktop/src/features/profile/avatarUrlImport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { uploadMediaBytes, type BlobDescriptor } from "@/shared/api/tauri";
import { isRelayHostedAvatarUrl } from "@/shared/lib/avatarUrl";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";

const MAX_IMPORTED_AVATAR_BYTES = 10 * 1024 * 1024;
const IMPORT_TIMEOUT_MS = 15_000;
const ACCEPTED_IMPORTED_AVATAR_TYPES = new Set([
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
]);

type UploadFn = (data: number[], filename?: string) => Promise<BlobDescriptor>;

type FetchFn = typeof fetch;

export type ImportAvatarUrlDependencies = {
fetchFn?: FetchFn;
uploadFn?: UploadFn;
relayOrigin: string | null;
};

export async function importAvatarUrl(
rawUrl: string,
{
fetchFn = fetch,
relayOrigin,
uploadFn = uploadMediaBytes,
}: ImportAvatarUrlDependencies,
): Promise<BlobDescriptor> {
const sourceUrl = parseImportUrl(rawUrl);
const fetchUrl = isRelayHostedAvatarUrl(sourceUrl, relayOrigin)
? rewriteRelayUrl(sourceUrl)
: sourceUrl;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), IMPORT_TIMEOUT_MS);
try {
const response = await fetchFn(fetchUrl, {
credentials: "omit",
referrerPolicy: "no-referrer",
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Could not fetch avatar image (${response.status}).`);
}
assertImportImageHeaders(response.headers);
const blob = await response.blob();
assertImportImageBlob(blob);
const buffer = await blob.arrayBuffer();
const uploaded = await uploadFn(
[...new Uint8Array(buffer)],
filenameForImportedAvatar(sourceUrl, blob.type),
);
if (!isAcceptedAvatarImageType(uploaded.type)) {
throw new Error("Choose a PNG, JPG, GIF, or WebP image.");
}
return uploaded;
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("Avatar image fetch timed out.");
}
throw error;
} finally {
clearTimeout(timeout);
}
}

export function parseImportUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new Error("Enter a valid HTTP(S) image URL.");
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new Error("Enter a valid HTTP(S) image URL.");
}
return parsed.toString();
}

function assertImportImageHeaders(headers: Headers): void {
const contentType = headers.get("content-type")?.toLowerCase() ?? "";
if (contentType && !isAcceptedAvatarImageType(contentType)) {
throw new Error("Choose a PNG, JPG, GIF, or WebP image.");
}
const contentLength = Number(headers.get("content-length") ?? "0");
if (contentLength > MAX_IMPORTED_AVATAR_BYTES) {
throw new Error("Avatar image is too large.");
}
}

function assertImportImageBlob(blob: Blob): void {
if (blob.size === 0) {
throw new Error("Avatar image is empty.");
}
if (blob.size > MAX_IMPORTED_AVATAR_BYTES) {
throw new Error("Avatar image is too large.");
}
if (blob.type && !isAcceptedAvatarImageType(blob.type)) {
throw new Error("Choose a PNG, JPG, GIF, or WebP image.");
}
}

function isAcceptedAvatarImageType(contentType: string): boolean {
return ACCEPTED_IMPORTED_AVATAR_TYPES.has(
contentType.toLowerCase().split(";")[0]?.trim() ?? "",
);
}

function filenameForImportedAvatar(
sourceUrl: string,
contentType: string,
): string {
const pathSegment = new URL(sourceUrl).pathname.split("/").pop() ?? "";
const cleanName = pathSegment.replace(/[^\w.-]/gu, "_");
if (/\.(?:png|jpe?g|gif|webp)$/iu.test(cleanName)) return cleanName;
return `avatar.${extensionForImageType(contentType)}`;
}

function extensionForImageType(contentType: string): string {
switch (contentType.toLowerCase()) {
case "image/gif":
return "gif";
case "image/jpeg":
return "jpg";
case "image/webp":
return "webp";
default:
return "png";
}
}
34 changes: 32 additions & 2 deletions desktop/src/features/profile/ui/AvatarUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export function AvatarUpload({
testIdPrefix = "avatar",
}: AvatarUploadProps) {
const [isDragging, setIsDragging] = React.useState(false);
const [urlDraft, setUrlDraft] = React.useState(avatarUrl);
const isUrlInputFocusedRef = React.useRef(false);

const onUploadSuccess = React.useCallback(
(url: string) => {
Expand All @@ -46,6 +48,7 @@ export function AvatarUpload({
clearError,
openPicker,
handleFileChange,
uploadUrl,
} = useAvatarUpload({ onUploadSuccess });

React.useEffect(() => {
Expand All @@ -54,6 +57,19 @@ export function AvatarUpload({

const isInputDisabled = disabled || isUploading;

React.useEffect(() => {
if (!isUrlInputFocusedRef.current) setUrlDraft(avatarUrl);
}, [avatarUrl]);

const applyUrl = React.useCallback(() => {
const nextUrl = urlDraft.trim();
if (nextUrl.length === 0 || isInputDisabled) return;
clearError();
void uploadUrl(nextUrl).then((uploaded) => {
if (uploaded) setUrlDraft("");
});
}, [clearError, isInputDisabled, uploadUrl, urlDraft]);

const handleDrop = React.useCallback(
(e: React.DragEvent) => {
e.preventDefault();
Expand Down Expand Up @@ -185,12 +201,26 @@ export function AvatarUpload({
data-testid={`${testIdPrefix}-url`}
disabled={isInputDisabled}
id={`${testIdPrefix}-url`}
onBlur={() => {
isUrlInputFocusedRef.current = false;
applyUrl();
}}
onChange={(event) => {
clearError();
onUrlChange(event.target.value);
setUrlDraft(event.target.value);
}}
onFocus={() => {
isUrlInputFocusedRef.current = true;
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
applyUrl();
}
}}
placeholder="https://example.com/avatar.png"
value={avatarUrl}
type="url"
value={urlDraft}
/>
</div>
<p className="text-xs text-muted-foreground">
Expand Down
16 changes: 11 additions & 5 deletions desktop/src/features/profile/ui/ProfileAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { UserRound } from "lucide-react";

import { useAvatarPresentation } from "@/features/profile/avatarPresentationStore";
import { parseAnimatedAvatarUrl } from "@/shared/lib/animatedAvatar";
import { resolveAvatarImageSrc } from "@/shared/lib/avatarUrl";
import { cn } from "@/shared/lib/cn";
import { getInitials } from "@/shared/lib/initials";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar";
import { Spinner } from "@/shared/ui/spinner";

Expand All @@ -31,6 +32,7 @@ export function ProfileAvatar({
testId,
}: ProfileAvatarProps) {
const initials = getInitials(label);
const relayOrigin = useRelayOrigin();
const presentation = useAvatarPresentation(avatarUrl);
const presentedAvatarUrl = presentation?.displayUrl ?? avatarUrl;

Expand All @@ -46,15 +48,19 @@ export function ProfileAvatar({

// Compute the live (proxied) source. Failures are tracked per resolved URL so
// the poster and hover animation can recover independently.
const liveSrc = baseUrl ? rewriteRelayUrl(baseUrl) : null;
const liveSrc = baseUrl ? resolveAvatarImageSrc(baseUrl, relayOrigin) : null;
const isBlockedAvatarUrl =
baseUrl !== undefined && baseUrl !== null && liveSrc === null;
const [failedSrc, setFailedSrc] = React.useState<string | null>(null);
const liveFailed = liveSrc !== null && failedSrc === liveSrc;

// When the relay is unreachable the proxied avatar URL 404s/times out; fall
// back to the locally cached data URL instead of dropping to initials.
const src = liveFailed
? (avatarDataUrl ?? undefined)
: (liveSrc ?? avatarDataUrl ?? undefined);
const src = isBlockedAvatarUrl
? undefined
: liveFailed
? (avatarDataUrl ?? undefined)
: (liveSrc ?? avatarDataUrl ?? undefined);
const shouldShowFallback = src === undefined || (!animated && liveFailed);

return (
Expand Down
9 changes: 5 additions & 4 deletions desktop/src/features/profile/ui/ProfileAvatarEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export function ProfileAvatarEditor({
isUploading,
openPicker,
uploadFile,
uploadUrl,
} = useAvatarUpload(uploadPreviewLifecycle);
const isInputDisabled = disabled || isUploading || isAnimatedApplyPending;
const handleAnimatedApply = React.useCallback(
Expand Down Expand Up @@ -379,14 +380,16 @@ export function ProfileAvatarEditor({

clearUploadError();
onUploadedAvatarChange?.(null);
onUrlChange(nextUrl);
void uploadUrl(nextUrl).then((uploaded) => {
if (uploaded) setUrlDraft("");
});
hasUserEditedUrlDraftRef.current = false;
updateMode("image");
}, [
clearUploadError,
isInputDisabled,
onUploadedAvatarChange,
onUrlChange,
uploadUrl,
updateMode,
urlDraft,
]);
Expand Down Expand Up @@ -698,8 +701,6 @@ export function ProfileAvatarEditor({
clearUploadError();
hasUserEditedUrlDraftRef.current = true;
setUrlDraft(event.target.value);
onUploadedAvatarChange?.(null);
onUrlChange(event.target.value);
}}
onFocus={() => {
isUrlInputFocusedRef.current = true;
Expand Down
Loading