diff --git a/src/lib/api/upsert-profile.ts b/src/lib/api/upsert-profile.ts new file mode 100644 index 000000000..08c706fc8 --- /dev/null +++ b/src/lib/api/upsert-profile.ts @@ -0,0 +1,90 @@ +import { + AppBskyActorProfile, + type BskyAgent, + ComAtprotoRepoPutRecord, + type Un$Typed, +} from '@atproto/api' +import {retry} from '@atproto/common-web' + +type ProfileUpdateFn = ( + existing?: AppBskyActorProfile.Record, +) => + | Un$Typed + | Promise> + +function readExistingProfile( + value: unknown, + allowInvalid: boolean, +): AppBskyActorProfile.Record | undefined { + if (!value || typeof value !== 'object') { + return undefined + } + const record = {$type: 'app.bsky.actor.profile', ...value} + const validation = AppBskyActorProfile.validateRecord(record) + if (validation.success) { + return validation.value + } + return allowInvalid ? (record as AppBskyActorProfile.Record) : undefined +} + +function profileRecordNeedsValidationSkip( + record: Un$Typed, +): boolean { + return record.avatar?.mimeType === 'image/webp' +} + +/** + * Upsert the actor profile record. Skips lexicon validation when the profile + * contains a webp avatar, which the lexicon does not accept but Witchsky + * uploads for smaller file sizes. + */ +export async function upsertActorProfile( + agent: BskyAgent, + updateFn: ProfileUpdateFn, +): Promise { + const collection = 'app.bsky.actor.profile' + + const upsert = async () => { + const repo = agent.assertDid + const existing = await agent.com.atproto.repo + .getRecord({repo, collection, rkey: 'self'}) + .catch(_ => undefined) + + // Read existing without validation first — webp avatars fail lexicon checks. + const existingRecord = existing + ? readExistingProfile(existing.data.value, true) + : undefined + + const updated = await updateFn(existingRecord) + const record = {$type: collection, ...updated} + const validate = !profileRecordNeedsValidationSkip(updated) + + if (validate) { + const validation = AppBskyActorProfile.validateRecord(record) + if (!validation.success) { + throw validation.error + } + await agent.com.atproto.repo.putRecord({ + repo, + collection, + rkey: 'self', + record: validation.value, + swapRecord: existing?.data.cid || null, + }) + } else { + await agent.com.atproto.repo.putRecord({ + repo, + collection, + rkey: 'self', + record, + swapRecord: existing?.data.cid || null, + validate: false, + }) + } + } + + return retry(upsert, { + maxRetries: 5, + retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError, + }) +} diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 907dce0e6..5037cfc7d 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -27,16 +27,22 @@ import {mimeToExt} from './video/util' export async function compressIfNeeded( img: PickerImage, maxSize: number = POST_IMG_MAX.size, + opts?: {outputMime?: 'image/jpeg' | 'image/webp'; forceEncode?: boolean}, ): Promise { - if (img.size < maxSize) { + const outputMime = opts?.outputMime ?? 'image/jpeg' + const needsReencode = + opts?.forceEncode || img.size >= maxSize || img.mime !== outputMime + + if (!needsReencode) { return img } + const resizedImage = await doResize(normalizePath(img.path), { width: img.width, height: img.height, mode: 'stretch', maxSize, - outputMime: 'image/jpeg', + outputMime, }) const finalImageMovedPath = await moveToPermanentPath( resizedImage.path, diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index 0470245af..fbd4d9551 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -1,21 +1,28 @@ +import {POST_IMG_MAX} from '#/lib/constants' import {type PickerImage} from './picker.shared' import {type Dimensions} from './types' -import {blobToDataUri, getDataUriSize} from './util' +import {blobToDataUri, convertCdnPreset, getDataUriSize} from './util' import {mimeToExt} from './video/util' export async function compressIfNeeded( img: PickerImage, - maxSize: number, + maxSize: number = POST_IMG_MAX.size, + opts?: {outputMime?: 'image/jpeg' | 'image/webp'; forceEncode?: boolean}, ): Promise { - if (img.size < maxSize) { + const outputMime = opts?.outputMime ?? 'image/jpeg' + const needsReencode = + opts?.forceEncode || img.size >= maxSize || img.mime !== outputMime + + if (!needsReencode) { return img } + return await doResize(img.path, { width: img.width, height: img.height, mode: 'stretch', maxSize, - outputMime: 'image/jpeg', + outputMime: opts?.outputMime ?? 'image/jpeg', }) } @@ -44,9 +51,16 @@ export async function shareImageModal(_opts: {uri: string}) { throw new Error('TODO') } -export async function saveImageToMediaLibrary(_opts: {uri: string}) { - // TODO - throw new Error('TODO') +/** + * Saves an image to the user's device. Uses the CDN's `download` preset + * which serves a JPEG with `Content-Disposition: attachment`. On web this + * triggers a browser download via a temporary anchor — no fetch needed. + */ +export async function saveImageToMediaLibrary({uri}: {uri: string}) { + const downloadUri = convertCdnPreset(uri, 'download') + const segments = downloadUri.split('/') + const filename = `bluesky-${segments.at(-1) ?? 'image'}.jpg` + downloadUrl(downloadUri, filename) } export async function downloadVideoWeb({uri}: {uri: string}) { diff --git a/src/screens/Onboarding/StepFinished/index.tsx b/src/screens/Onboarding/StepFinished/index.tsx index da7433be9..3320f60a6 100644 --- a/src/screens/Onboarding/StepFinished/index.tsx +++ b/src/screens/Onboarding/StepFinished/index.tsx @@ -14,6 +14,7 @@ import {Trans} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {uploadBlob} from '#/lib/api' +import {upsertActorProfile} from '#/lib/api/upsert-profile' import { BSKY_APP_ACCOUNT_DID, DISCOVER_SAVED_FEED, @@ -146,7 +147,7 @@ export function StepFinished() { ? uploadBlob(agent, imageUri, imageMime) : undefined - await agent.upsertProfile(async existing => { + await upsertActorProfile(agent, async existing => { let next: Un$Typed = existing ?? {} if (blobPromise) { diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 6c84ec4c8..efef9f453 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -212,7 +212,10 @@ export function StepProfile() { } } } - image = await compressIfNeeded(image, 1000000) + image = await compressIfNeeded(image, 1000000, { + outputMime: 'image/webp', + forceEncode: true, + }) // If we are on mobile, prefetching the image will load the image into memory before we try and display it, // stopping any brief flickers. diff --git a/src/state/gallery.ts b/src/state/gallery.ts index b599ac7cf..d5706a9c3 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -287,11 +287,15 @@ export async function compressImage( highResolution?: boolean increasedBlobSizeLimit?: boolean outputMime?: 'image/jpeg' | 'image/webp' + /** When true, always re-encode even if the source is already small enough. */ + forceEncode?: boolean }, ): Promise { - const res = await bypassCompression(img) - if (res) { - return res + if (!options?.forceEncode) { + const res = await bypassCompression(img) + if (res) { + return res + } } const source = img.transformed || img.source diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 9d2fabe86..9eedb79d4 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -20,6 +20,7 @@ import { } from '@tanstack/react-query' import {uploadBlob} from '#/lib/api' +import {upsertActorProfile} from '#/lib/api/upsert-profile' import {until} from '#/lib/async/until' import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' import {updateProfileShadow} from '#/state/cache/profile-shadow' @@ -172,7 +173,7 @@ export function useProfileUpdateMutation() { newUserBanner.mime, ) } - await agent.upsertProfile(async existing => { + await upsertActorProfile(agent, async existing => { let next: Un$Typed = existing || {} if (typeof updates === 'function') { next = updates(next) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 3f2b8f332..f14a33689 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -17,6 +17,7 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' +import {POST_IMG_MAX} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import { useCameraPermission, @@ -404,6 +405,14 @@ let EditableUserAvatar = ({ const circular = type !== 'algo' && type !== 'list' && !enableSquareAvatars + const avatarCompressOpts = useMemo( + () => + type === 'list' + ? {outputMime: 'image/jpeg' as const} + : {outputMime: 'image/webp' as const, forceEncode: true}, + [type], + ) + const aviStyle = useMemo(() => { if (!circular) { return { @@ -429,9 +438,11 @@ let EditableUserAvatar = ({ await openCamera({ aspect: [1, 1], }), + POST_IMG_MAX.size, + avatarCompressOpts, ), ) - }, [onSelectNewAvatar, requestCameraAccessIfNeeded]) + }, [onSelectNewAvatar, requestCameraAccessIfNeeded, avatarCompressOpts]) const onOpenLibrary = useCallback(async () => { if (!(await requestPhotoAccessIfNeeded())) { @@ -457,6 +468,8 @@ let EditableUserAvatar = ({ shape: circular ? 'circle' : 'rectangle', aspectRatio: 1, }), + POST_IMG_MAX.size, + avatarCompressOpts, ), ) } else { @@ -475,6 +488,7 @@ let EditableUserAvatar = ({ sheetWrapper, editImageDialogControl, circular, + avatarCompressOpts, ]) const onRemoveAvatar = useCallback(() => { @@ -483,10 +497,13 @@ let EditableUserAvatar = ({ const onChangeEditImage = useCallback( async (image: ComposerImage) => { - const compressed = await compressImage(image, {outputMime: 'image/jpeg'}) + const compressed = await compressImage(image, { + ...avatarCompressOpts, + forceEncode: true, + }) onSelectNewAvatar(compressed) }, - [onSelectNewAvatar], + [onSelectNewAvatar, avatarCompressOpts], ) return ( diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 76f58ddfa..cb8cf6ef5 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -115,7 +115,10 @@ export function UserBanner({ const onChangeEditImage = useCallback( async (image: ComposerImage) => { - const compressed = await compressImage(image, {outputMime: 'image/jpeg'}) + const compressed = await compressImage(image, { + outputMime: 'image/jpeg', + forceEncode: true, + }) onSelectNewBanner?.(compressed) }, [onSelectNewBanner],