3652b30811
The main typecheck models native resolution (.ios/.android/.native), so .web variants were never checked in their real resolution context. Add a second pass that roots the program at the web entry point and resolves with moduleSuffixes [".web", ""], wire it up as pnpm typecheck:web, and add it to the lint workflow matrix. Ambient shims (src/platform/*.web-check.d.ts, web pass only) pin react-native-svg, expo-file-system, and expo-image-manipulator to their native declarations, since their .web.d.ts files expose a different API surface than the one the app is written against (and, for expo-file-system/legacy, raw package sources that break under suffix remapping). The rest fixes the drift the new pass surfaced, notably: - List.web: ListRef now carries ListMethods (was RefObject<View>), scrollToOffset accepts optional animated, ListProps defaults ItemT - Dialog.web: ScrollableInner accepts a (never-attached) ScrollView ref, InnerFlatList label optional + ListMethods ref, Handle accepts props - Menu.web exports MenuControlProps; ContextMenu.web gets a properly typed Trigger wrapper and accepts align - Pager/TabBar/PagerWithHeader web variants align with the shared contract (testID, onTabPressed, drag shared values, setMinimumHeight) - useNavigationTabState.web now reports isAtFeeds/isAtBookmarks, fixing drawer highlighting for those routes on web - Web-only throw-stubs get real signatures; duplicated prop types move into .shared.ts files (CaptchaWebView, EditImageDialog, OpenCameraBtn, GestureActionView, ValuePropositionPager) - findListNativeTag helper replaces findNodeHandle casts on List refs - Assorted call-site type fixes (keyExtractor params, PostQuotes record narrowing, saveBytesToDisk bytes param, openCamera web signature) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCuMKXWHuyGoNhTVAHBMyk
199 lines
5.0 KiB
TypeScript
199 lines
5.0 KiB
TypeScript
import {type PickerImage} from './picker.shared'
|
|
import {type Dimensions} from './types'
|
|
import {
|
|
blobToDataUri,
|
|
convertCdnPreset,
|
|
getDataUriSize,
|
|
getResizedDimensions,
|
|
} from './util'
|
|
|
|
export async function compressIfNeeded(
|
|
img: PickerImage,
|
|
{maxDimension, maxSize}: {maxDimension: number; maxSize: number},
|
|
): Promise<PickerImage> {
|
|
if (img.size < maxSize) {
|
|
return img
|
|
}
|
|
return await doResize(img.path, {
|
|
maxDimension,
|
|
maxSize,
|
|
})
|
|
}
|
|
|
|
export interface DownloadAndResizeOpts {
|
|
uri: string
|
|
maxDimension: number
|
|
maxSize: number
|
|
timeout: number
|
|
}
|
|
|
|
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
|
const controller = new AbortController()
|
|
const to = setTimeout(() => controller.abort(), opts.timeout || 5e3)
|
|
const res = await fetch(opts.uri)
|
|
const resBody = await res.blob()
|
|
clearTimeout(to)
|
|
|
|
const dataUri = await blobToDataUri(resBody)
|
|
return await doResize(dataUri, {
|
|
maxDimension: opts.maxDimension,
|
|
maxSize: opts.maxSize,
|
|
})
|
|
}
|
|
|
|
export async function shareImageModal(_opts: {uri: string}) {
|
|
// TODO
|
|
throw new Error('TODO')
|
|
}
|
|
|
|
/**
|
|
* Saves an image to the user's device. Uses the CDN's `download` preset
|
|
* which uses the JPEG version with the Content-Disposition header set to
|
|
* `attachment; filename=<filename>`. On native this saves to the media library;
|
|
* on web it triggers a browser download.
|
|
*/
|
|
export async function saveImageToMediaLibrary({uri}: {uri: string}) {
|
|
const downloadUri = convertCdnPreset(uri, 'download')
|
|
const segments = downloadUri.split('/')
|
|
const filename = `bluesky-${segments.at(-1)}.jpg`
|
|
downloadUrl(downloadUri, filename)
|
|
}
|
|
|
|
export async function getImageDim(path: string): Promise<Dimensions> {
|
|
var img = document.createElement('img')
|
|
const promise = new Promise((resolve, reject) => {
|
|
img.onload = resolve
|
|
img.onerror = reject
|
|
})
|
|
img.src = path
|
|
await promise
|
|
return {width: img.width, height: img.height}
|
|
}
|
|
|
|
// internal methods
|
|
// =
|
|
|
|
interface DoResizeOpts {
|
|
maxDimension: number
|
|
maxSize: number
|
|
}
|
|
|
|
async function doResize(
|
|
dataUri: string,
|
|
opts: DoResizeOpts,
|
|
): Promise<PickerImage> {
|
|
const sourceDims = await getImageDim(dataUri)
|
|
const newDimensions = getResizedDimensions(sourceDims, opts.maxDimension)
|
|
|
|
let newDataUri
|
|
|
|
let minQualityPercentage = 0
|
|
let maxQualityPercentage = 101 //exclusive
|
|
|
|
while (maxQualityPercentage - minQualityPercentage > 1) {
|
|
const qualityPercentage = Math.round(
|
|
(maxQualityPercentage + minQualityPercentage) / 2,
|
|
)
|
|
const tempDataUri = await createResizedImage(dataUri, {
|
|
width: newDimensions.width,
|
|
height: newDimensions.height,
|
|
quality: qualityPercentage / 100,
|
|
mode: 'contain',
|
|
})
|
|
|
|
if (getDataUriSize(tempDataUri) < opts.maxSize) {
|
|
minQualityPercentage = qualityPercentage
|
|
newDataUri = tempDataUri
|
|
} else {
|
|
maxQualityPercentage = qualityPercentage
|
|
}
|
|
}
|
|
|
|
if (!newDataUri) {
|
|
throw new Error('Failed to compress image')
|
|
}
|
|
return {
|
|
path: newDataUri,
|
|
mime: 'image/jpeg',
|
|
size: getDataUriSize(newDataUri),
|
|
width: newDimensions.width,
|
|
height: newDimensions.height,
|
|
}
|
|
}
|
|
|
|
function createResizedImage(
|
|
dataUri: string,
|
|
{
|
|
width,
|
|
height,
|
|
quality,
|
|
mode,
|
|
}: {
|
|
width: number
|
|
height: number
|
|
quality: number
|
|
mode: 'contain' | 'cover' | 'stretch'
|
|
},
|
|
): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const img = document.createElement('img')
|
|
img.addEventListener('load', () => {
|
|
const canvas = document.createElement('canvas')
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) {
|
|
return reject(new Error('Failed to resize image'))
|
|
}
|
|
|
|
let scale = 1
|
|
if (mode === 'cover') {
|
|
scale = img.width < img.height ? width / img.width : height / img.height
|
|
} else if (mode === 'contain') {
|
|
scale = img.width > img.height ? width / img.width : height / img.height
|
|
}
|
|
let w = img.width * scale
|
|
let h = img.height * scale
|
|
|
|
canvas.width = w
|
|
canvas.height = h
|
|
|
|
ctx.drawImage(img, 0, 0, w, h)
|
|
resolve(canvas.toDataURL('image/jpeg', quality))
|
|
})
|
|
img.addEventListener('error', ev => {
|
|
reject(ev.error)
|
|
})
|
|
img.src = dataUri
|
|
})
|
|
}
|
|
|
|
export async function saveBytesToDisk(
|
|
filename: string,
|
|
bytes: Uint8Array,
|
|
type: string,
|
|
) {
|
|
/*
|
|
* Bytes handed to us are never SharedArrayBuffer-backed, but the broader
|
|
* Uint8Array parameter type matches the native variant.
|
|
*/
|
|
const blob = new Blob([bytes as Uint8Array<ArrayBuffer>], {type})
|
|
const url = URL.createObjectURL(blob)
|
|
downloadUrl(url, filename)
|
|
// Firefox requires a small delay
|
|
setTimeout(() => URL.revokeObjectURL(url), 100)
|
|
return true
|
|
}
|
|
|
|
function downloadUrl(href: string, filename: string) {
|
|
const a = document.createElement('a')
|
|
a.href = href
|
|
a.download = filename
|
|
a.style.display = 'none'
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
}
|
|
|
|
export async function safeDeleteAsync() {
|
|
// no-op
|
|
}
|