deer social fork for personal usage. but you might see a use idk. github mirror
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

much better upload on web and test on android

ayla a725d3c9 ecf2f354

+323 -21
+2
package.json
··· 88 88 "@fortawesome/react-native-fontawesome": "^0.3.2", 89 89 "@haileyok/bluesky-video": "0.3.2", 90 90 "@ipld/dag-cbor": "^9.2.0", 91 + "@jsquash/webp": "^1.5.0", 91 92 "@lingui/react": "^4.14.1", 92 93 "@mattermost/react-native-paste-input": "mattermost/react-native-paste-input", 93 94 "@miblanchard/react-native-slider": "^2.6.0", ··· 205 206 "react-native-view-shot": "^4.0.3", 206 207 "react-native-web": "^0.21.0", 207 208 "react-native-web-webview": "^1.0.2", 209 + "react-native-webp-converter": "^0.2.0", 208 210 "react-native-webview": "^13.13.5", 209 211 "react-remove-scroll-bar": "^2.3.8", 210 212 "react-responsive": "^10.0.1",
+53 -21
src/state/gallery.ts
··· 1 + import * as WebP from 'react-native-webp-converter' 1 2 import { 2 3 cacheDirectory, 3 4 deleteAsync, ··· 10 11 manipulateAsync, 11 12 SaveFormat, 12 13 } from 'expo-image-manipulator' 14 + import { 15 + type ImageResult, 16 + type SaveOptions, 17 + } from 'expo-image-manipulator/src/ImageManipulator.types' 13 18 import {nanoid} from 'nanoid/non-secure' 14 19 15 20 import {POST_IMG_MAX} from '#/lib/constants' ··· 17 22 import {openCropper} from '#/lib/media/picker' 18 23 import {type PickerImage} from '#/lib/media/picker.shared' 19 24 import {getDataUriSize} from '#/lib/media/util' 20 - import {isNative} from '#/platform/detection' 21 25 22 26 export type ImageTransformation = { 23 27 crop?: ActionCrop['crop'] ··· 54 58 let _imageCacheDirectory: string 55 59 56 60 function getImageCacheDirectory(): string | null { 57 - if (isNative) { 58 - return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer')) 59 - } 60 - 61 - return null 61 + return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer')) 62 62 } 63 63 64 64 export async function createComposerImage( ··· 119 119 } 120 120 121 121 export async function cropImage(img: ComposerImage): Promise<ComposerImage> { 122 - if (!isNative) { 123 - return img 124 - } 125 - 126 122 const source = img.source 127 123 128 124 // @todo: we're always passing the original image here, does image-cropper ··· 197 193 198 194 export async function compressImage(img: ComposerImage): Promise<PickerImage> { 199 195 const source = img.transformed || img.source 200 - const originalSize = getDataUriSize(img.source.path) + 1024 196 + const originalSize = getDataUriSize(img.source.path) 201 197 202 198 const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) 203 199 ··· 208 204 while (maxQualityPercentage > 1) { 209 205 const qualityPercentage = Math.round(maxQualityPercentage - 10) 210 206 211 - const res = await manipulateAsync( 207 + const res = await manipulateWebp( 212 208 source.path, 213 - [{resize: {width: w, height: h}}], 209 + {resize: {width: w, height: h}}, 214 210 { 215 - compress: qualityPercentage / 100, 211 + compress: qualityPercentage, 216 212 format: SaveFormat.WEBP, 217 - base64: true, 218 213 }, 219 214 ) 220 215 221 - const base64 = res.base64 222 - const size = base64 ? getDataUriSize(base64) : 0 223 - if (base64 && size <= POST_IMG_MAX.size && size <= originalSize) { 216 + if (res.size <= POST_IMG_MAX.size && res.size <= originalSize) { 224 217 newDataUri = { 225 218 path: await moveIfNecessary(res.uri), 226 219 width: res.width, 227 220 height: res.height, 228 221 mime: 'image/webp', 229 - size, 222 + size: res.size, 230 223 quality: qualityPercentage, 231 224 } 232 225 break ··· 242 235 throw new Error(`Unable to compress image`) 243 236 } 244 237 238 + export const manipulateWebp = async ( 239 + uri: string, 240 + resize: {resize: {width: number; height: number}} = { 241 + resize: {width: 128, height: 128}, 242 + }, 243 + saveOptions: SaveOptions = {}, 244 + ): Promise<ImageResult & {size: number}> => { 245 + const resized = await manipulateAsync(uri, [resize], { 246 + format: SaveFormat.PNG, 247 + }) 248 + const tempOut = (await getTemporaryImageFile()) as string 249 + 250 + const resultUri = await WebP.convertImage(resized.uri, tempOut, { 251 + type: saveOptions.compress === 100 ? WebP.Type.LOSSLESS : WebP.Type.LOSSY, 252 + quality: saveOptions.compress || 100, 253 + }) 254 + 255 + const blob = await (await fetch(resultUri)).blob() 256 + 257 + return { 258 + uri: resultUri, 259 + width: resize.resize.width, 260 + height: resize.resize.height, 261 + size: blob.size, 262 + } 263 + } 264 + 245 265 async function moveIfNecessary(from: string) { 246 - const cacheDir = isNative && getImageCacheDirectory() 266 + const cacheDir = getImageCacheDirectory() 247 267 248 268 if (cacheDir && from.startsWith(cacheDir)) { 249 269 const to = joinPath(cacheDir, nanoid(36)) ··· 257 277 return from 258 278 } 259 279 280 + async function getTemporaryImageFile() { 281 + const cacheDir = getImageCacheDirectory() 282 + 283 + if (cacheDir) { 284 + const path = joinPath(cacheDir, nanoid(36)) 285 + 286 + await makeDirectoryAsync(cacheDir, {intermediates: true}) 287 + 288 + return path 289 + } 290 + } 291 + 260 292 /** Purge files that were created to accomodate image manipulation */ 261 293 export async function purgeTemporaryImageFiles() { 262 - const cacheDir = isNative && getImageCacheDirectory() 294 + const cacheDir = getImageCacheDirectory() 263 295 264 296 if (cacheDir) { 265 297 await deleteAsync(cacheDir, {idempotent: true})
+251
src/state/gallery.web.ts
··· 1 + import { 2 + type Action, 3 + type ActionCrop, 4 + type ImageResult, 5 + manipulateAsync, 6 + SaveFormat, 7 + type SaveOptions, 8 + } from 'expo-image-manipulator' 9 + import {encode} from '@jsquash/webp' 10 + import {nanoid} from 'nanoid/non-secure' 11 + 12 + import {POST_IMG_MAX} from '#/lib/constants' 13 + import {getImageDim} from '#/lib/media/manip' 14 + import {type PickerImage} from '#/lib/media/picker.shared' 15 + import {getDataUriSize} from '#/lib/media/util' 16 + import {resize} from '../../node_modules/expo-image-manipulator/src/web/actions/index.web' 17 + 18 + export type ImageTransformation = { 19 + crop?: ActionCrop['crop'] 20 + } 21 + 22 + export type ImageMeta = { 23 + path: string 24 + width: number 25 + height: number 26 + mime: string 27 + } 28 + 29 + export type ImageSource = ImageMeta & { 30 + id: string 31 + } 32 + 33 + type ComposerImageBase = { 34 + alt: string 35 + source: ImageSource 36 + } 37 + type ComposerImageWithoutTransformation = ComposerImageBase & { 38 + transformed?: undefined 39 + manips?: undefined 40 + } 41 + type ComposerImageWithTransformation = ComposerImageBase & { 42 + transformed: ImageMeta 43 + manips?: ImageTransformation 44 + } 45 + 46 + export type ComposerImage = 47 + | ComposerImageWithoutTransformation 48 + | ComposerImageWithTransformation 49 + 50 + export async function createComposerImage( 51 + raw: ImageMeta, 52 + ): Promise<ComposerImageWithoutTransformation> { 53 + return { 54 + alt: '', 55 + source: { 56 + id: nanoid(), 57 + path: raw.path, 58 + width: raw.width, 59 + height: raw.height, 60 + mime: raw.mime, 61 + }, 62 + } 63 + } 64 + 65 + export type InitialImage = { 66 + uri: string 67 + width: number 68 + height: number 69 + altText?: string 70 + } 71 + 72 + export function createInitialImages( 73 + uris: InitialImage[] = [], 74 + ): ComposerImageWithoutTransformation[] { 75 + return uris.map(({uri, width, height, altText = ''}) => { 76 + return { 77 + alt: altText, 78 + source: { 79 + id: nanoid(), 80 + path: uri, 81 + width: width, 82 + height: height, 83 + mime: 'image/png', 84 + }, 85 + } 86 + }) 87 + } 88 + 89 + export async function pasteImage( 90 + uri: string, 91 + ): Promise<ComposerImageWithoutTransformation> { 92 + const {width, height} = await getImageDim(uri) 93 + const match = /^data:(.+?);/.exec(uri) 94 + 95 + return { 96 + alt: '', 97 + source: { 98 + id: nanoid(), 99 + path: uri, 100 + width: width, 101 + height: height, 102 + mime: match ? match[1] : 'image/png', 103 + }, 104 + } 105 + } 106 + 107 + export async function cropImage(img: ComposerImage): Promise<ComposerImage> { 108 + return img 109 + } 110 + 111 + export async function manipulateImage( 112 + img: ComposerImage, 113 + trans: ImageTransformation, 114 + ): Promise<ComposerImage> { 115 + const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}] 116 + 117 + const actions = rawActions.filter((a): a is Action => a !== undefined) 118 + 119 + if (actions.length === 0) { 120 + if (img.transformed === undefined) { 121 + return img 122 + } 123 + 124 + return {alt: img.alt, source: img.source} 125 + } 126 + 127 + const source = img.source 128 + const result = await manipulateAsync(source.path, actions, { 129 + format: SaveFormat.PNG, 130 + }) 131 + 132 + return { 133 + alt: img.alt, 134 + source: img.source, 135 + transformed: { 136 + path: result.uri, 137 + width: result.width, 138 + height: result.height, 139 + mime: 'image/png', 140 + }, 141 + manips: trans, 142 + } 143 + } 144 + 145 + export function resetImageManipulation( 146 + img: ComposerImage, 147 + ): ComposerImageWithoutTransformation { 148 + if (img.transformed !== undefined) { 149 + return {alt: img.alt, source: img.source} 150 + } 151 + 152 + return img 153 + } 154 + 155 + export async function compressImage(img: ComposerImage): Promise<PickerImage> { 156 + const source = img.transformed || img.source 157 + const originalSize = getDataUriSize(img.source.path) 158 + 159 + const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) 160 + 161 + let maxQualityPercentage = 162 + 110 - (originalSize >= POST_IMG_MAX.size * 2 ? 10 : 0) // exclusive 163 + let newDataUri 164 + 165 + while (maxQualityPercentage > 1) { 166 + const qualityPercentage = Math.round(maxQualityPercentage - 10) 167 + 168 + const res = await manipulateWebp(source.path, { 169 + compress: qualityPercentage, 170 + format: SaveFormat.WEBP, 171 + resize: {width: w, height: h}, 172 + }) 173 + 174 + if (res.size <= POST_IMG_MAX.size && res.size <= originalSize) { 175 + newDataUri = { 176 + path: res.uri, 177 + width: res.width, 178 + height: res.height, 179 + mime: 'image/webp', 180 + size: res.size, 181 + quality: qualityPercentage, 182 + } 183 + break 184 + } else { 185 + maxQualityPercentage = qualityPercentage 186 + } 187 + } 188 + 189 + if (newDataUri) { 190 + return newDataUri 191 + } 192 + 193 + throw new Error(`Unable to compress image`) 194 + } 195 + 196 + export const manipulateWebp = async ( 197 + uri: string, 198 + saveOptions: SaveOptions & {resize?: {width: number; height: number}} = {}, 199 + ): Promise<ImageResult & {size: number}> => { 200 + const img = document.createElement('img') 201 + img.src = uri 202 + await new Promise(resolve => { 203 + img.onload = resolve 204 + }) 205 + const canvas = document.createElement('canvas') 206 + ;[canvas.width, canvas.height] = [img.width, img.height] 207 + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D 208 + ctx.drawImage(img, 0, 0) 209 + 210 + if (saveOptions.resize) { 211 + resize(canvas, saveOptions.resize) 212 + } 213 + 214 + const rawImageData = ctx.getImageData(0, 0, img.width, img.height) 215 + 216 + const webpBuffer = await encode(rawImageData, { 217 + lossless: saveOptions.compress === 100 ? 1 : 0, 218 + quality: saveOptions.compress || 100, 219 + method: 6, 220 + }) 221 + 222 + const blob = new Blob([webpBuffer], {type: 'image/webp'}) 223 + const resultUri = URL.createObjectURL(blob) 224 + 225 + return { 226 + uri: resultUri, 227 + width: rawImageData.width, 228 + height: rawImageData.height, 229 + size: blob.size, 230 + } 231 + } 232 + 233 + function containImageRes( 234 + w: number, 235 + h: number, 236 + {width: maxW, height: maxH}: {width: number; height: number}, 237 + ): [width: number, height: number] { 238 + let scale = 1 239 + 240 + if (w > maxW || h > maxH) { 241 + scale = w > h ? maxW / w : maxH / h 242 + w = Math.floor(w * scale) 243 + h = Math.floor(h * scale) 244 + } 245 + 246 + return [w, h] 247 + } 248 + 249 + export async function purgeTemporaryImageFiles() { 250 + return null 251 + }
+17
yarn.lock
··· 5277 5277 resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" 5278 5278 integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== 5279 5279 5280 + "@jsquash/webp@^1.5.0": 5281 + version "1.5.0" 5282 + resolved "https://registry.yarnpkg.com/@jsquash/webp/-/webp-1.5.0.tgz#1e8ce357cde2decf4f880a8c436f8f1529cd3f48" 5283 + integrity sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw== 5284 + dependencies: 5285 + wasm-feature-detect "^1.2.11" 5286 + 5280 5287 "@leichtgewicht/ip-codec@^2.0.1": 5281 5288 version "2.0.4" 5282 5289 resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" ··· 17584 17591 postcss-value-parser "^4.2.0" 17585 17592 styleq "^0.1.3" 17586 17593 17594 + react-native-webp-converter@^0.2.0: 17595 + version "0.2.0" 17596 + resolved "https://registry.yarnpkg.com/react-native-webp-converter/-/react-native-webp-converter-0.2.0.tgz#efca6f57b909c9d861005e52b7becc084b483f4b" 17597 + integrity sha512-gFmHU9f4H3xFy37r306ZgQNj3H5gYVE+wFHzrx6nQ1gBrceT6tKXJRj3FQ+knzTI1dkPkRXc+MM+3IsjYJo9Jg== 17598 + 17587 17599 react-native-webview@^13.13.5: 17588 17600 version "13.15.0" 17589 17601 resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-13.15.0.tgz#b6d2f8d8dd65897db76659ddd8198d2c74ec5a79" ··· 20274 20286 version "0.1.1" 20275 20287 resolved "https://registry.yarnpkg.com/warn-once/-/warn-once-0.1.1.tgz#952088f4fb56896e73fd4e6a3767272a3fccce43" 20276 20288 integrity sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q== 20289 + 20290 + wasm-feature-detect@^1.2.11: 20291 + version "1.8.0" 20292 + resolved "https://registry.yarnpkg.com/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz#4e9f55b0a64d801f372fbb0324ed11ad3abd0c78" 20293 + integrity sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ== 20277 20294 20278 20295 watchpack@^2.4.0: 20279 20296 version "2.4.0"