refactor: redo composer image state management
This commit is contained in:
+14
-39
@@ -13,20 +13,19 @@ import {
|
|||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {ComposerImage, compressImage} from '#/state/gallery'
|
||||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||||
import {isNetworkError} from 'lib/strings/errors'
|
import {isNetworkError} from 'lib/strings/errors'
|
||||||
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
||||||
import {isNative, isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
import {ImageModel} from 'state/models/media/image'
|
|
||||||
import {LinkMeta} from '../link-meta/link-meta'
|
import {LinkMeta} from '../link-meta/link-meta'
|
||||||
import {safeDeleteAsync} from '../media/manip'
|
|
||||||
|
|
||||||
export interface ExternalEmbedDraft {
|
export interface ExternalEmbedDraft {
|
||||||
uri: string
|
uri: string
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
meta?: LinkMeta
|
meta?: LinkMeta
|
||||||
embed?: AppBskyEmbedRecord.Main
|
embed?: AppBskyEmbedRecord.Main
|
||||||
localThumb?: ImageModel
|
localThumb?: ComposerImage
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadBlob(
|
export async function uploadBlob(
|
||||||
@@ -56,7 +55,7 @@ interface PostOpts {
|
|||||||
cid: string
|
cid: string
|
||||||
}
|
}
|
||||||
extLink?: ExternalEmbedDraft
|
extLink?: ExternalEmbedDraft
|
||||||
images?: ImageModel[]
|
images?: ComposerImage[]
|
||||||
labels?: string[]
|
labels?: string[]
|
||||||
threadgate?: ThreadgateSetting[]
|
threadgate?: ThreadgateSetting[]
|
||||||
onStateChange?: (state: string) => void
|
onStateChange?: (state: string) => void
|
||||||
@@ -113,18 +112,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
|||||||
const images: AppBskyEmbedImages.Image[] = []
|
const images: AppBskyEmbedImages.Image[] = []
|
||||||
for (const image of opts.images) {
|
for (const image of opts.images) {
|
||||||
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
||||||
|
|
||||||
logger.debug(`Compressing image`)
|
logger.debug(`Compressing image`)
|
||||||
await image.compress()
|
const {path, width, height, mime} = await compressImage(image)
|
||||||
const path = image.compressed?.path ?? image.path
|
|
||||||
const {width, height} = image.compressed || image
|
|
||||||
logger.debug(`Uploading image`)
|
logger.debug(`Uploading image`)
|
||||||
const res = await uploadBlob(agent, path, 'image/jpeg')
|
const res = await uploadBlob(agent, path, mime)
|
||||||
if (isNative) {
|
|
||||||
safeDeleteAsync(path)
|
|
||||||
}
|
|
||||||
images.push({
|
images.push({
|
||||||
image: res.data.blob,
|
image: res.data.blob,
|
||||||
alt: image.altText ?? '',
|
alt: image.alt,
|
||||||
aspectRatio: {width, height},
|
aspectRatio: {width, height},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -154,32 +151,10 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
|||||||
let thumb
|
let thumb
|
||||||
if (opts.extLink.localThumb) {
|
if (opts.extLink.localThumb) {
|
||||||
opts.onStateChange?.('Uploading link thumbnail...')
|
opts.onStateChange?.('Uploading link thumbnail...')
|
||||||
let encoding
|
const {path, mime} = opts.extLink.localThumb.source
|
||||||
if (opts.extLink.localThumb.mime) {
|
const res = await uploadBlob(agent, path, mime)
|
||||||
encoding = opts.extLink.localThumb.mime
|
|
||||||
} else if (opts.extLink.localThumb.path.endsWith('.png')) {
|
thumb = res.data.blob
|
||||||
encoding = 'image/png'
|
|
||||||
} else if (
|
|
||||||
opts.extLink.localThumb.path.endsWith('.jpeg') ||
|
|
||||||
opts.extLink.localThumb.path.endsWith('.jpg')
|
|
||||||
) {
|
|
||||||
encoding = 'image/jpeg'
|
|
||||||
} else {
|
|
||||||
logger.warn('Unexpected image format for thumbnail, skipping', {
|
|
||||||
thumbnail: opts.extLink.localThumb.path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (encoding) {
|
|
||||||
const thumbUploadRes = await uploadBlob(
|
|
||||||
agent,
|
|
||||||
opts.extLink.localThumb.path,
|
|
||||||
encoding,
|
|
||||||
)
|
|
||||||
thumb = thumbUploadRes.data.blob
|
|
||||||
if (isNative) {
|
|
||||||
safeDeleteAsync(opts.extLink.localThumb.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.quote) {
|
if (opts.quote) {
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import uuid from 'react-native-uuid'
|
||||||
|
import {
|
||||||
|
cacheDirectory,
|
||||||
|
deleteAsync,
|
||||||
|
makeDirectoryAsync,
|
||||||
|
moveAsync,
|
||||||
|
} from 'expo-file-system'
|
||||||
|
import {
|
||||||
|
Action,
|
||||||
|
ActionCrop,
|
||||||
|
ActionExtent,
|
||||||
|
ActionFlip,
|
||||||
|
ActionResize,
|
||||||
|
ActionRotate,
|
||||||
|
manipulateAsync,
|
||||||
|
SaveFormat,
|
||||||
|
} from 'expo-image-manipulator'
|
||||||
|
|
||||||
|
import {POST_IMG_MAX} from '#/lib/constants'
|
||||||
|
import {getImageDim} from '#/lib/media/manip'
|
||||||
|
import {openCropper} from '#/lib/media/picker'
|
||||||
|
import {getDataUriSize} from '#/lib/media/util'
|
||||||
|
import {isIOS, isNative} from '#/platform/detection'
|
||||||
|
|
||||||
|
export type ImageTransformation = Partial<
|
||||||
|
ActionCrop & ActionExtent & ActionFlip & ActionResize & ActionRotate
|
||||||
|
>
|
||||||
|
|
||||||
|
export type ImageSource = {
|
||||||
|
path: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
mime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComposerImageBase = {
|
||||||
|
alt: string
|
||||||
|
source: ImageSource
|
||||||
|
compressed?: ImageSource
|
||||||
|
}
|
||||||
|
type ComposerImageWithoutTransformation = ComposerImageBase & {
|
||||||
|
transformed?: undefined
|
||||||
|
}
|
||||||
|
type ComposerImageWithTransformation = ComposerImageBase & {
|
||||||
|
transformed: ImageSource
|
||||||
|
transformations?: ImageTransformation
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComposerImage =
|
||||||
|
| ComposerImageWithoutTransformation
|
||||||
|
| ComposerImageWithTransformation
|
||||||
|
|
||||||
|
const imageCacheDirectory = isNative
|
||||||
|
? joinPath(cacheDirectory!, 'bsky-composer')
|
||||||
|
: null
|
||||||
|
|
||||||
|
export async function createComposerImage(
|
||||||
|
raw: ImageSource,
|
||||||
|
): Promise<ComposerImageWithoutTransformation> {
|
||||||
|
return {
|
||||||
|
alt: '',
|
||||||
|
source: {
|
||||||
|
path: await moveIfNecessary(raw.path),
|
||||||
|
width: raw.width,
|
||||||
|
height: raw.height,
|
||||||
|
mime: raw.mime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialImages(
|
||||||
|
uris: {uri: string; width: number; height: number}[] | undefined,
|
||||||
|
): ComposerImageWithoutTransformation[] {
|
||||||
|
if (uris === undefined) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return uris.map(({uri, width, height}) => {
|
||||||
|
return {
|
||||||
|
alt: '',
|
||||||
|
source: {
|
||||||
|
path: uri,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pasteImage(
|
||||||
|
uri: string,
|
||||||
|
): Promise<ComposerImageWithoutTransformation> {
|
||||||
|
const {width, height} = await getImageDim(uri)
|
||||||
|
|
||||||
|
return {
|
||||||
|
alt: '',
|
||||||
|
source: {
|
||||||
|
path: uri,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cropImage(img: ComposerImage): Promise<ComposerImage> {
|
||||||
|
if (!isNative) {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE
|
||||||
|
// on ios, react-native-image-crop-picker gives really bad quality
|
||||||
|
// without specifying width and height. on android, however, the
|
||||||
|
// crop stretches incorrectly if you do specify it. these are
|
||||||
|
// both separate bugs in the library. we deal with that by
|
||||||
|
// providing width & height for ios only
|
||||||
|
// -prf
|
||||||
|
|
||||||
|
const source = img.source
|
||||||
|
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
|
||||||
|
|
||||||
|
// @todo: we're always passing the original image here, does image-cropper
|
||||||
|
// allows for setting initial crop dimensions? -mary
|
||||||
|
try {
|
||||||
|
const cropped = await openCropper({
|
||||||
|
mediaType: 'photo',
|
||||||
|
path: source.path,
|
||||||
|
freeStyleCropEnabled: true,
|
||||||
|
...(isIOS ? {width: w, height: h} : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
alt: img.alt,
|
||||||
|
source: source,
|
||||||
|
transformed: {
|
||||||
|
path: await moveIfNecessary(cropped.path),
|
||||||
|
width: cropped.width,
|
||||||
|
height: cropped.height,
|
||||||
|
mime: cropped.mime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message.includes('User cancelled')) {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function manipulateImage(
|
||||||
|
img: ComposerImage,
|
||||||
|
trans: ImageTransformation,
|
||||||
|
): Promise<ComposerImage> {
|
||||||
|
const rawActions: (Action | undefined)[] = [
|
||||||
|
trans.extent && {extent: trans.extent},
|
||||||
|
trans.flip && {flip: trans.flip},
|
||||||
|
trans.rotate !== undefined ? {rotate: trans.rotate} : undefined,
|
||||||
|
trans.resize && {resize: trans.resize},
|
||||||
|
trans.crop && {crop: trans.crop},
|
||||||
|
]
|
||||||
|
|
||||||
|
const actions = rawActions.filter((a): a is Action => a !== undefined)
|
||||||
|
|
||||||
|
if (actions.length === 0) {
|
||||||
|
if (img.transformed === undefined) {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
return {alt: img.alt, source: img.source}
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = img.source
|
||||||
|
const result = await manipulateAsync(source.path, actions, {
|
||||||
|
format: SaveFormat.PNG,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
alt: img.alt,
|
||||||
|
source: img.source,
|
||||||
|
transformed: {
|
||||||
|
path: await moveIfNecessary(result.uri),
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
transformations: trans,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetImageManipulation(
|
||||||
|
img: ComposerImage,
|
||||||
|
): ComposerImageWithoutTransformation {
|
||||||
|
if (img.transformed !== undefined) {
|
||||||
|
return {alt: img.alt, source: img.source}
|
||||||
|
}
|
||||||
|
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compressImage(img: ComposerImage): Promise<ImageSource> {
|
||||||
|
const source = img.transformed || img.source
|
||||||
|
|
||||||
|
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
|
||||||
|
|
||||||
|
for (let i = 10; i > 0; i--) {
|
||||||
|
// Float precision
|
||||||
|
const factor = i / 10
|
||||||
|
|
||||||
|
const res = await manipulateAsync(
|
||||||
|
source.path,
|
||||||
|
[{resize: {width: w, height: h}}],
|
||||||
|
{
|
||||||
|
compress: factor,
|
||||||
|
format: SaveFormat.JPEG,
|
||||||
|
base64: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const base64 = res.base64
|
||||||
|
|
||||||
|
if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) {
|
||||||
|
return {
|
||||||
|
path: await moveIfNecessary(res.uri),
|
||||||
|
width: res.width,
|
||||||
|
height: res.height,
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageCacheDirectory) {
|
||||||
|
await deleteAsync(res.uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unable to compress image`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveIfNecessary(from: string) {
|
||||||
|
if (cacheDirectory && from.startsWith(cacheDirectory!)) {
|
||||||
|
const to = joinPath(imageCacheDirectory!, uuid.v4() + '')
|
||||||
|
await moveAsync({from, to})
|
||||||
|
|
||||||
|
return to
|
||||||
|
}
|
||||||
|
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Purge files that were created to accomodate image manipulation */
|
||||||
|
export async function purgeTemporaryImageFiles() {
|
||||||
|
if (imageCacheDirectory) {
|
||||||
|
await deleteAsync(imageCacheDirectory, {idempotent: true})
|
||||||
|
await makeDirectoryAsync(imageCacheDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPath(a: string, b: string) {
|
||||||
|
if (a.endsWith('/')) {
|
||||||
|
if (b.startsWith('/')) {
|
||||||
|
return a.slice(0, -1) + b
|
||||||
|
}
|
||||||
|
return a + b
|
||||||
|
} else if (b.startsWith('/')) {
|
||||||
|
return a + b
|
||||||
|
}
|
||||||
|
return a + '/' + b
|
||||||
|
}
|
||||||
|
|
||||||
|
function containImageRes(
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
{width: maxW, height: maxH}: {width: number; height: number},
|
||||||
|
): [width: number, height: number] {
|
||||||
|
let scale = 1
|
||||||
|
|
||||||
|
if (w > maxW || h > maxH) {
|
||||||
|
scale = w < h ? maxW / w : maxH / h
|
||||||
|
w = Math.floor(w * scale)
|
||||||
|
h = Math.floor(h * scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [w, h]
|
||||||
|
}
|
||||||
@@ -3,8 +3,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
|
|||||||
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {GalleryModel} from '#/state/models/media/gallery'
|
import {ComposerImage} from '../gallery'
|
||||||
import {ImageModel} from '#/state/models/media/image'
|
|
||||||
import {ThreadgateSetting} from '../queries/threadgate'
|
import {ThreadgateSetting} from '../queries/threadgate'
|
||||||
|
|
||||||
export interface EditProfileModal {
|
export interface EditProfileModal {
|
||||||
@@ -40,8 +39,8 @@ export interface ListAddRemoveUsersModal {
|
|||||||
|
|
||||||
export interface EditImageModal {
|
export interface EditImageModal {
|
||||||
name: 'edit-image'
|
name: 'edit-image'
|
||||||
image: ImageModel
|
image: ComposerImage
|
||||||
gallery: GalleryModel
|
onChange: (next: ComposerImage) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CropImageModal {
|
export interface CropImageModal {
|
||||||
@@ -53,7 +52,8 @@ export interface CropImageModal {
|
|||||||
|
|
||||||
export interface AltTextImageModal {
|
export interface AltTextImageModal {
|
||||||
name: 'alt-text-image'
|
name: 'alt-text-image'
|
||||||
image: ImageModel
|
image: ComposerImage
|
||||||
|
onChange: (next: ComposerImage) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteAccountModal {
|
export interface DeleteAccountModal {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {RichText} from '@atproto/api'
|
|||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {observer} from 'mobx-react-lite'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createGIFDescription,
|
createGIFDescription,
|
||||||
@@ -26,6 +25,7 @@ import {LikelyType} from '#/lib/link-meta/link-meta'
|
|||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {emitPostCreated} from '#/state/events'
|
import {emitPostCreated} from '#/state/events'
|
||||||
|
import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery'
|
||||||
import {useModals} from '#/state/modals'
|
import {useModals} from '#/state/modals'
|
||||||
import {useRequireAltTextEnabled} from '#/state/preferences'
|
import {useRequireAltTextEnabled} from '#/state/preferences'
|
||||||
import {
|
import {
|
||||||
@@ -50,7 +50,6 @@ import {shortenLinks} from 'lib/strings/rich-text-manip'
|
|||||||
import {colors, gradients, s} from 'lib/styles'
|
import {colors, gradients, s} from 'lib/styles'
|
||||||
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
||||||
import {useDialogStateControlContext} from 'state/dialogs'
|
import {useDialogStateControlContext} from 'state/dialogs'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
|
||||||
import {ComposerOpts} from 'state/shell/composer'
|
import {ComposerOpts} from 'state/shell/composer'
|
||||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
@@ -77,8 +76,10 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
|
|||||||
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
||||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
||||||
|
|
||||||
|
const MAX_IMAGES = 4
|
||||||
|
|
||||||
type Props = ComposerOpts
|
type Props = ComposerOpts
|
||||||
export const ComposePost = observer(function ComposePost({
|
export const ComposePost = ({
|
||||||
replyTo,
|
replyTo,
|
||||||
onPost,
|
onPost,
|
||||||
quote: initQuote,
|
quote: initQuote,
|
||||||
@@ -86,7 +87,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
openPicker,
|
openPicker,
|
||||||
text: initText,
|
text: initText,
|
||||||
imageUris: initImageUris,
|
imageUris: initImageUris,
|
||||||
}: Props) {
|
}: Props) => {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {getAgent} = useAgent()
|
const {getAgent} = useAgent()
|
||||||
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
||||||
@@ -123,6 +124,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
const graphemeLength = useMemo(() => {
|
const graphemeLength = useMemo(() => {
|
||||||
return shortenLinks(richtext).graphemeLength
|
return shortenLinks(richtext).graphemeLength
|
||||||
}, [richtext])
|
}, [richtext])
|
||||||
|
|
||||||
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
||||||
initQuote,
|
initQuote,
|
||||||
)
|
)
|
||||||
@@ -130,10 +132,11 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
const [extGif, setExtGif] = useState<Gif>()
|
const [extGif, setExtGif] = useState<Gif>()
|
||||||
const [labels, setLabels] = useState<string[]>([])
|
const [labels, setLabels] = useState<string[]>([])
|
||||||
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
|
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
|
||||||
const gallery = useMemo(
|
|
||||||
() => new GalleryModel(initImageUris),
|
const [images, setImages] = useState<ComposerImage[]>(() =>
|
||||||
[initImageUris],
|
createInitialImages(initImageUris),
|
||||||
)
|
)
|
||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
closeComposer()
|
closeComposer()
|
||||||
}, [closeComposer])
|
}, [closeComposer])
|
||||||
@@ -149,7 +152,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onPressCancel = useCallback(() => {
|
const onPressCancel = useCallback(() => {
|
||||||
if (graphemeLength > 0 || !gallery.isEmpty) {
|
if (graphemeLength > 0 || images.length !== 0) {
|
||||||
closeAllDialogs()
|
closeAllDialogs()
|
||||||
if (Keyboard) {
|
if (Keyboard) {
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
@@ -160,7 +163,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
graphemeLength,
|
graphemeLength,
|
||||||
gallery.isEmpty,
|
images.length,
|
||||||
closeAllDialogs,
|
closeAllDialogs,
|
||||||
discardPromptControl,
|
discardPromptControl,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -207,18 +210,27 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
[extLink, setExtLink],
|
[extLink, setExtLink],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onImageAdd = useCallback(
|
||||||
|
(next: ComposerImage[]) => {
|
||||||
|
setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length)))
|
||||||
|
},
|
||||||
|
[setImages],
|
||||||
|
)
|
||||||
|
|
||||||
const onPhotoPasted = useCallback(
|
const onPhotoPasted = useCallback(
|
||||||
async (uri: string) => {
|
async (uri: string) => {
|
||||||
track('Composer:PastedPhotos')
|
track('Composer:PastedPhotos')
|
||||||
await gallery.paste(uri)
|
|
||||||
|
const res = await pasteImage(uri)
|
||||||
|
onImageAdd([res])
|
||||||
},
|
},
|
||||||
[gallery, track],
|
[track, onImageAdd],
|
||||||
)
|
)
|
||||||
|
|
||||||
const isAltTextRequiredAndMissing = useMemo(() => {
|
const isAltTextRequiredAndMissing = useMemo(() => {
|
||||||
if (!requireAltTextEnabled) return false
|
if (!requireAltTextEnabled) return false
|
||||||
|
|
||||||
if (gallery.needsAltText) return true
|
if (images.some(img => img.alt === '')) return true
|
||||||
if (extGif) {
|
if (extGif) {
|
||||||
if (!extLink?.meta?.description) return true
|
if (!extLink?.meta?.description) return true
|
||||||
|
|
||||||
@@ -226,7 +238,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
if (!parsedAlt.isPreferred) return true
|
if (!parsedAlt.isPreferred) return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
}, [images, extLink, extGif, requireAltTextEnabled])
|
||||||
|
|
||||||
const onPressPublish = async () => {
|
const onPressPublish = async () => {
|
||||||
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||||
@@ -241,7 +253,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
richtext.text.trim().length === 0 &&
|
richtext.text.trim().length === 0 &&
|
||||||
gallery.isEmpty &&
|
images.length === 0 &&
|
||||||
!extLink &&
|
!extLink &&
|
||||||
!quote
|
!quote
|
||||||
) {
|
) {
|
||||||
@@ -261,7 +273,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
await apilib.post(getAgent(), {
|
await apilib.post(getAgent(), {
|
||||||
rawText: richtext.text,
|
rawText: richtext.text,
|
||||||
replyTo: replyTo?.uri,
|
replyTo: replyTo?.uri,
|
||||||
images: gallery.images,
|
images: images,
|
||||||
quote,
|
quote,
|
||||||
extLink,
|
extLink,
|
||||||
labels,
|
labels,
|
||||||
@@ -273,7 +285,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(e, {
|
logger.error(e, {
|
||||||
message: `Composer: create post failed`,
|
message: `Composer: create post failed`,
|
||||||
hasImages: gallery.size > 0,
|
hasImages: images.length > 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (extLink) {
|
if (extLink) {
|
||||||
@@ -289,7 +301,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
} finally {
|
} finally {
|
||||||
if (postUri) {
|
if (postUri) {
|
||||||
logEvent('post:create', {
|
logEvent('post:create', {
|
||||||
imageCount: gallery.size,
|
imageCount: images.length,
|
||||||
isReply: replyTo != null,
|
isReply: replyTo != null,
|
||||||
hasLink: extLink != null,
|
hasLink: extLink != null,
|
||||||
hasQuote: quote != null,
|
hasQuote: quote != null,
|
||||||
@@ -298,7 +310,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
track('Create Post', {
|
track('Create Post', {
|
||||||
imageCount: gallery.size,
|
imageCount: images.length,
|
||||||
})
|
})
|
||||||
if (replyTo && replyTo.uri) track('Post:Reply')
|
if (replyTo && replyTo.uri) track('Post:Reply')
|
||||||
}
|
}
|
||||||
@@ -323,8 +335,8 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
? _(msg`Write your reply`)
|
? _(msg`Write your reply`)
|
||||||
: _(msg`What's up?`)
|
: _(msg`What's up?`)
|
||||||
|
|
||||||
const canSelectImages = gallery.size < 4 && !extLink
|
const canSelectImages = images.length < MAX_IMAGES && !extLink
|
||||||
const hasMedia = gallery.size > 0 || Boolean(extLink)
|
const hasMedia = images.length > 0 || Boolean(extLink)
|
||||||
|
|
||||||
const onEmojiButtonPress = useCallback(() => {
|
const onEmojiButtonPress = useCallback(() => {
|
||||||
openPicker?.(textInput.current?.getCursorPosition())
|
openPicker?.(textInput.current?.getCursorPosition())
|
||||||
@@ -509,8 +521,8 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Gallery gallery={gallery} />
|
<Gallery images={images} onChange={setImages} />
|
||||||
{gallery.isEmpty && extLink && (
|
{images.length === 0 && extLink && (
|
||||||
<View style={a.relative}>
|
<View style={a.relative}>
|
||||||
<ExternalEmbed
|
<ExternalEmbed
|
||||||
link={extLink}
|
link={extLink}
|
||||||
@@ -541,8 +553,12 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
<SuggestedLanguage text={richtext.text} />
|
<SuggestedLanguage text={richtext.text} />
|
||||||
<View style={[pal.border, styles.bottomBar]}>
|
<View style={[pal.border, styles.bottomBar]}>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
<SelectPhotoBtn
|
||||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
size={images.length}
|
||||||
|
disabled={!canSelectImages}
|
||||||
|
onAdd={onImageAdd}
|
||||||
|
/>
|
||||||
|
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
|
||||||
<SelectGifBtn
|
<SelectGifBtn
|
||||||
onClose={focusTextInput}
|
onClose={focusTextInput}
|
||||||
onSelectGif={onSelectGif}
|
onSelectGif={onSelectGif}
|
||||||
@@ -577,7 +593,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
/>
|
/>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
outer: {
|
outer: {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const ExternalEmbed = ({
|
|||||||
title: link.meta?.title ?? link.uri,
|
title: link.meta?.title ?? link.uri,
|
||||||
uri: link.uri,
|
uri: link.uri,
|
||||||
description: link.meta?.description ?? '',
|
description: link.meta?.description ?? '',
|
||||||
thumb: link.localThumb?.path,
|
thumb: link.localThumb?.source.path,
|
||||||
},
|
},
|
||||||
[link],
|
[link],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import React, {useState} from 'react'
|
import React from 'react'
|
||||||
import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
|
import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
|
||||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {observer} from 'mobx-react-lite'
|
|
||||||
|
|
||||||
|
import {ComposerImage, cropImage} from '#/state/gallery'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {Dimensions} from 'lib/media/types'
|
import {Dimensions} from 'lib/media/types'
|
||||||
import {colors, s} from 'lib/styles'
|
import {colors, s} from 'lib/styles'
|
||||||
import {isNative} from 'platform/detection'
|
import {isNative} from 'platform/detection'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
|
||||||
import {Text} from 'view/com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
import {useTheme} from '#/alf'
|
import {useTheme} from '#/alf'
|
||||||
|
|
||||||
const IMAGE_GAP = 8
|
const IMAGE_GAP = 8
|
||||||
|
|
||||||
interface GalleryProps {
|
interface GalleryProps {
|
||||||
gallery: GalleryModel
|
images: ComposerImage[]
|
||||||
|
onChange: (next: ComposerImage[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Gallery = (props: GalleryProps) => {
|
export let Gallery = (props: GalleryProps): React.ReactNode => {
|
||||||
const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
|
const [containerInfo, setContainerInfo] = React.useState<Dimensions>()
|
||||||
|
|
||||||
const onLayout = (evt: LayoutChangeEvent) => {
|
const onLayout = (evt: LayoutChangeEvent) => {
|
||||||
const {width, height} = evt.nativeEvent.layout
|
const {width, height} = evt.nativeEvent.layout
|
||||||
@@ -41,15 +41,13 @@ export const Gallery = (props: GalleryProps) => {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Gallery = React.memo(Gallery)
|
||||||
|
|
||||||
interface GalleryInnerProps extends GalleryProps {
|
interface GalleryInnerProps extends GalleryProps {
|
||||||
containerInfo: Dimensions
|
containerInfo: Dimensions
|
||||||
}
|
}
|
||||||
|
|
||||||
const GalleryInner = observer(function GalleryImpl({
|
const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => {
|
||||||
gallery,
|
|
||||||
containerInfo,
|
|
||||||
}: GalleryInnerProps) {
|
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
const {openModal} = useModalControls()
|
const {openModal} = useModalControls()
|
||||||
@@ -57,10 +55,11 @@ const GalleryInner = observer(function GalleryImpl({
|
|||||||
|
|
||||||
let side: number
|
let side: number
|
||||||
|
|
||||||
if (gallery.size === 1) {
|
if (images.length === 1) {
|
||||||
side = 250
|
side = 250
|
||||||
} else {
|
} else {
|
||||||
side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size
|
side =
|
||||||
|
(containerInfo.width - IMAGE_GAP * (images.length - 1)) / images.length
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageStyle = {
|
const imageStyle = {
|
||||||
@@ -68,66 +67,82 @@ const GalleryInner = observer(function GalleryImpl({
|
|||||||
width: side,
|
width: side,
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOverflow = isMobile && gallery.size > 2
|
const isOverflow = isMobile && images.length > 2
|
||||||
|
|
||||||
const altTextControlStyle = isOverflow
|
const altTextControlStyle = isOverflow
|
||||||
? {
|
? {left: 4, bottom: 4}
|
||||||
left: 4,
|
: !isMobile && images.length < 3
|
||||||
bottom: 4,
|
? {left: 8, top: 8}
|
||||||
}
|
: {left: 4, top: 4}
|
||||||
: !isMobile && gallery.size < 3
|
|
||||||
? {
|
|
||||||
left: 8,
|
|
||||||
top: 8,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
left: 4,
|
|
||||||
top: 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageControlsStyle = {
|
const imageControlsStyle = {
|
||||||
display: 'flex' as const,
|
display: 'flex' as const,
|
||||||
flexDirection: 'row' as const,
|
flexDirection: 'row' as const,
|
||||||
position: 'absolute' as const,
|
position: 'absolute' as const,
|
||||||
...(isOverflow
|
...(isOverflow
|
||||||
? {
|
? {top: 4, right: 4, gap: 4}
|
||||||
top: 4,
|
: !isMobile && images.length < 3
|
||||||
right: 4,
|
? {top: 8, right: 8, gap: 8}
|
||||||
gap: 4,
|
: {top: 4, right: 4, gap: 4}),
|
||||||
}
|
|
||||||
: !isMobile && gallery.size < 3
|
|
||||||
? {
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
gap: 8,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
gap: 4,
|
|
||||||
}),
|
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
return !gallery.isEmpty ? (
|
return images.length !== 0 ? (
|
||||||
<>
|
<>
|
||||||
<View testID="selectedPhotosView" style={styles.gallery}>
|
<View testID="selectedPhotosView" style={styles.gallery}>
|
||||||
{gallery.images.map(image => (
|
{images.map((image, index) => {
|
||||||
<View key={`selected-image-${image.path}`} style={[imageStyle]}>
|
const onRemove = () => {
|
||||||
|
const next = images.slice()
|
||||||
|
next.splice(index, 1)
|
||||||
|
|
||||||
|
onChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAltText = () => {
|
||||||
|
Keyboard.dismiss()
|
||||||
|
openModal({
|
||||||
|
name: 'alt-text-image',
|
||||||
|
image,
|
||||||
|
onChange(next) {
|
||||||
|
onChange(
|
||||||
|
images.map(i => (i.source === image.source ? next : i)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEdit = () => {
|
||||||
|
if (isNative) {
|
||||||
|
cropImage(image).then(next => {
|
||||||
|
if (next === image) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(
|
||||||
|
images.map(i => (i.source === image.source ? next : i)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// openModal({
|
||||||
|
// name: 'edit-image',
|
||||||
|
// image,
|
||||||
|
// gallery,
|
||||||
|
// })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={`selected-image-${image.source.path}`}
|
||||||
|
style={[imageStyle]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="altTextButton"
|
testID="altTextButton"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={_(msg`Add alt text`)}
|
accessibilityLabel={_(msg`Add alt text`)}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
onPress={() => {
|
onPress={onAltText}
|
||||||
Keyboard.dismiss()
|
|
||||||
openModal({
|
|
||||||
name: 'alt-text-image',
|
|
||||||
image,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
style={[styles.altTextControl, altTextControlStyle]}>
|
style={[styles.altTextControl, altTextControlStyle]}>
|
||||||
{image.altText.length > 0 ? (
|
{image.alt.length !== 0 ? (
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="check"
|
icon="check"
|
||||||
size={10}
|
size={10}
|
||||||
@@ -145,22 +160,13 @@ const GalleryInner = observer(function GalleryImpl({
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={imageControlsStyle}>
|
<View style={imageControlsStyle}>
|
||||||
|
{isNative && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="editPhotoButton"
|
testID="editPhotoButton"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={_(msg`Edit image`)}
|
accessibilityLabel={_(msg`Edit image`)}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
onPress={() => {
|
onPress={onEdit}
|
||||||
if (isNative) {
|
|
||||||
gallery.crop(image)
|
|
||||||
} else {
|
|
||||||
openModal({
|
|
||||||
name: 'edit-image',
|
|
||||||
image,
|
|
||||||
gallery,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={styles.imageControl}>
|
style={styles.imageControl}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="pen"
|
icon="pen"
|
||||||
@@ -168,12 +174,13 @@ const GalleryInner = observer(function GalleryImpl({
|
|||||||
style={{color: colors.white}}
|
style={{color: colors.white}}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="removePhotoButton"
|
testID="removePhotoButton"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={_(msg`Remove image`)}
|
accessibilityLabel={_(msg`Remove image`)}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
onPress={() => gallery.remove(image)}
|
onPress={onRemove}
|
||||||
style={styles.imageControl}>
|
style={styles.imageControl}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="xmark"
|
icon="xmark"
|
||||||
@@ -186,13 +193,7 @@ const GalleryInner = observer(function GalleryImpl({
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={_(msg`Add alt text`)}
|
accessibilityLabel={_(msg`Add alt text`)}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
onPress={() => {
|
onPress={onAltText}
|
||||||
Keyboard.dismiss()
|
|
||||||
openModal({
|
|
||||||
name: 'alt-text-image',
|
|
||||||
image,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
style={styles.altTextHiddenRegion}
|
style={styles.altTextHiddenRegion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -200,18 +201,19 @@ const GalleryInner = observer(function GalleryImpl({
|
|||||||
testID="selectedPhotoImage"
|
testID="selectedPhotoImage"
|
||||||
style={[styles.image, imageStyle] as ImageStyle}
|
style={[styles.image, imageStyle] as ImageStyle}
|
||||||
source={{
|
source={{
|
||||||
uri: image.cropped?.path ?? image.path,
|
uri: (image.transformed ?? image.source).path,
|
||||||
}}
|
}}
|
||||||
accessible={true}
|
accessible={true}
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityIgnoresInvertColors
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
<AltTextReminder />
|
<AltTextReminder />
|
||||||
</>
|
</>
|
||||||
) : null
|
) : null
|
||||||
})
|
}
|
||||||
|
|
||||||
export function AltTextReminder() {
|
export function AltTextReminder() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions'
|
|||||||
import {openCamera} from '#/lib/media/picker'
|
import {openCamera} from '#/lib/media/picker'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isMobileWeb, isNative} from '#/platform/detection'
|
import {isMobileWeb, isNative} from '#/platform/detection'
|
||||||
import {GalleryModel} from '#/state/models/media/gallery'
|
import {ComposerImage, createComposerImage} from '#/state/gallery'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
|
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gallery: GalleryModel
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
onAdd: (next: ComposerImage[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenCameraBtn({gallery, disabled}: Props) {
|
export function OpenCameraBtn({disabled, onAdd}: Props) {
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||||
@@ -48,13 +48,16 @@ export function OpenCameraBtn({gallery, disabled}: Props) {
|
|||||||
if (mediaPermissionRes) {
|
if (mediaPermissionRes) {
|
||||||
await MediaLibrary.createAssetAsync(img.path)
|
await MediaLibrary.createAssetAsync(img.path)
|
||||||
}
|
}
|
||||||
gallery.add(img)
|
|
||||||
|
const res = await createComposerImage(img)
|
||||||
|
|
||||||
|
onAdd([res])
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// ignore
|
// ignore
|
||||||
logger.warn('Error using camera', {error: err})
|
logger.warn('Error using camera', {error: err})
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
gallery,
|
onAdd,
|
||||||
track,
|
track,
|
||||||
requestCameraAccessIfNeeded,
|
requestCameraAccessIfNeeded,
|
||||||
mediaPermissionRes,
|
mediaPermissionRes,
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||||
|
import {openPicker} from '#/lib/media/picker'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {GalleryModel} from '#/state/models/media/gallery'
|
import {ComposerImage, createComposerImage} from '#/state/gallery'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
|
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gallery: GalleryModel
|
size: number
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
onAdd: (next: ComposerImage[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectPhotoBtn({gallery, disabled}: Props) {
|
export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||||
@@ -29,8 +31,17 @@ export function SelectPhotoBtn({gallery, disabled}: Props) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gallery.pick()
|
const images = await openPicker({
|
||||||
}, [track, requestPhotoAccessIfNeeded, gallery])
|
selectionLimit: 4 - size,
|
||||||
|
allowsMultipleSelection: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
images.map(img => createComposerImage(img)),
|
||||||
|
)
|
||||||
|
|
||||||
|
onAdd(results)
|
||||||
|
}, [track, requestPhotoAccessIfNeeded, size, onAdd])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {createComposerImage} from '#/state/gallery'
|
||||||
import {useFetchDid} from '#/state/queries/handle'
|
import {useFetchDid} from '#/state/queries/handle'
|
||||||
import {useGetPost} from '#/state/queries/post'
|
import {useGetPost} from '#/state/queries/post'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
isBskyListUrl,
|
isBskyListUrl,
|
||||||
isBskyPostUrl,
|
isBskyPostUrl,
|
||||||
} from 'lib/strings/url-helpers'
|
} from 'lib/strings/url-helpers'
|
||||||
import {ImageModel} from 'state/models/media/image'
|
|
||||||
import {ComposerOpts} from 'state/shell/composer'
|
import {ComposerOpts} from 'state/shell/composer'
|
||||||
|
|
||||||
export function useExternalLinkFetch({
|
export function useExternalLinkFetch({
|
||||||
@@ -118,14 +118,16 @@ export function useExternalLinkFetch({
|
|||||||
timeout: 15e3,
|
timeout: 15e3,
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.then(localThumb => {
|
.then(thumb => (thumb ? createComposerImage(thumb) : undefined))
|
||||||
|
.then(thumb => {
|
||||||
if (aborted) {
|
if (aborted) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setExtLink({
|
setExtLink({
|
||||||
...extLink,
|
...extLink,
|
||||||
isLoading: false, // done
|
isLoading: false, // done
|
||||||
localThumb: localThumb ? new ImageModel(localThumb) : undefined,
|
localThumb: thumb,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return cleanup
|
return cleanup
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {LinearGradient} from 'expo-linear-gradient'
|
|||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {ComposerImage} from '#/state/gallery'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
import {MAX_ALT_TEXT} from 'lib/constants'
|
import {MAX_ALT_TEXT} from 'lib/constants'
|
||||||
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
|
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
|
||||||
@@ -21,21 +22,21 @@ import {enforceLen} from 'lib/strings/helpers'
|
|||||||
import {gradients, s} from 'lib/styles'
|
import {gradients, s} from 'lib/styles'
|
||||||
import {useTheme} from 'lib/ThemeContext'
|
import {useTheme} from 'lib/ThemeContext'
|
||||||
import {isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
import {ImageModel} from 'state/models/media/image'
|
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {ScrollView, TextInput} from './util'
|
import {ScrollView, TextInput} from './util'
|
||||||
|
|
||||||
export const snapPoints = ['100%']
|
export const snapPoints = ['100%']
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
image: ImageModel
|
image: ComposerImage
|
||||||
|
onChange: (next: ComposerImage) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Component({image}: Props) {
|
export function Component({image, onChange}: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const [altText, setAltText] = useState(image.altText)
|
const [altText, setAltText] = useState(image.alt)
|
||||||
const windim = useWindowDimensions()
|
const windim = useWindowDimensions()
|
||||||
const {closeModal} = useModalControls()
|
const {closeModal} = useModalControls()
|
||||||
const inputRef = React.useRef<RNTextInput>(null)
|
const inputRef = React.useRef<RNTextInput>(null)
|
||||||
@@ -59,7 +60,9 @@ export function Component({image}: Props) {
|
|||||||
|
|
||||||
const imageStyles = useMemo<ImageStyle>(() => {
|
const imageStyles = useMemo<ImageStyle>(() => {
|
||||||
const maxWidth = isWeb ? 450 : windim.width
|
const maxWidth = isWeb ? 450 : windim.width
|
||||||
if (image.height > image.width) {
|
const source = image.transformed ?? image.source
|
||||||
|
|
||||||
|
if (source.height > source.width) {
|
||||||
return {
|
return {
|
||||||
resizeMode: 'contain',
|
resizeMode: 'contain',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -69,7 +72,7 @@ export function Component({image}: Props) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: (maxWidth / image.width) * image.height,
|
height: (maxWidth / source.width) * source.height,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
}
|
}
|
||||||
}, [image, windim])
|
}, [image, windim])
|
||||||
@@ -78,15 +81,14 @@ export function Component({image}: Props) {
|
|||||||
(v: string) => {
|
(v: string) => {
|
||||||
v = enforceLen(v, MAX_ALT_TEXT)
|
v = enforceLen(v, MAX_ALT_TEXT)
|
||||||
setAltText(v)
|
setAltText(v)
|
||||||
image.setAltText(v)
|
|
||||||
},
|
},
|
||||||
[setAltText, image],
|
[setAltText],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressSave = useCallback(() => {
|
const onPressSave = useCallback(() => {
|
||||||
image.setAltText(altText)
|
|
||||||
closeModal()
|
closeModal()
|
||||||
}, [closeModal, image, altText])
|
onChange({...image, alt: altText.trim()})
|
||||||
|
}, [closeModal, image, altText, onChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -101,7 +103,7 @@ export function Component({image}: Props) {
|
|||||||
testID="selectedPhotoImage"
|
testID="selectedPhotoImage"
|
||||||
style={imageStyles}
|
style={imageStyles}
|
||||||
source={{
|
source={{
|
||||||
uri: image.cropped?.path ?? image.path,
|
uri: (image.transformed ?? image.source).path,
|
||||||
}}
|
}}
|
||||||
contentFit="contain"
|
contentFit="contain"
|
||||||
accessible={true}
|
accessible={true}
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import React, {useEffect} from 'react'
|
import React, {useEffect} from 'react'
|
||||||
import {observer} from 'mobx-react-lite'
|
|
||||||
import {Animated, Easing, Platform, StyleSheet, View} from 'react-native'
|
import {Animated, Easing, Platform, StyleSheet, View} from 'react-native'
|
||||||
import {ComposePost} from '../com/composer/Composer'
|
|
||||||
import {useComposerState} from 'state/shell/composer'
|
import {purgeTemporaryImageFiles} from '#/state/gallery'
|
||||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useComposerState} from 'state/shell/composer'
|
||||||
|
import {ComposePost} from '../com/composer/Composer'
|
||||||
|
|
||||||
export const Composer = observer(function ComposerImpl({
|
export const Composer = ({winHeight}: {winHeight: number}) => {
|
||||||
winHeight,
|
|
||||||
}: {
|
|
||||||
winHeight: number
|
|
||||||
}) {
|
|
||||||
const state = useComposerState()
|
const state = useComposerState()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const initInterp = useAnimatedValue(0)
|
const initInterp = useAnimatedValue(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state) {
|
if (state) {
|
||||||
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
|
purgeTemporaryImageFiles()
|
||||||
|
}
|
||||||
|
|
||||||
Animated.timing(initInterp, {
|
Animated.timing(initInterp, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 300,
|
duration: 300,
|
||||||
@@ -60,7 +61,7 @@ export const Composer = observer(function ComposerImpl({
|
|||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
|
|||||||
Reference in New Issue
Block a user