+
diff --git a/jest/jestSetup.js b/jest/jestSetup.js
index a68c1dc4bf..50a33589ea 100644
--- a/jest/jestSetup.js
+++ b/jest/jestSetup.js
@@ -42,8 +42,16 @@ jest.mock('rn-fetch-blob', () => ({
fetch: jest.fn(),
}))
-jest.mock('@bam.tech/react-native-image-resizer', () => ({
- createResizedImage: jest.fn(),
+jest.mock('expo-file-system', () => ({
+ getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
+ deleteAsync: jest.fn(),
+}))
+
+jest.mock('expo-image-manipulator', () => ({
+ manipulateAsync: jest.fn().mockResolvedValue({
+ uri: 'file://resized-image',
+ }),
+ SaveFormat: jest.requireActual('expo-image-manipulator').SaveFormat,
}))
jest.mock('@segment/analytics-react-native', () => ({
diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift
index f863eaf223..481402890f 100644
--- a/modules/BlueskyNSE/NotificationService.swift
+++ b/modules/BlueskyNSE/NotificationService.swift
@@ -2,46 +2,80 @@ import UserNotifications
import UIKit
let APP_GROUP = "group.app.bsky"
+typealias ContentHandler = (UNNotificationContent) -> Void
+
+// This extension allows us to do some processing of the received notification
+// data before displaying the notification to the user. In our use case, there
+// are a few particular things that we want to do:
+//
+// - Determine whether we should play a sound for the notification
+// - Download and display any images for the notification
+// - Update the badge count accordingly
+//
+// The extension may or may not create a new process to handle a notification.
+// It is also possible that multiple notifications will be processed by the
+// same instance of `NotificationService`, though these will happen in
+// parallel.
+//
+// Because multiple instances of `NotificationService` may exist, we should
+// be careful in accessing preferences that will be mutated _by the
+// extension itself_. For example, we should not worry about `playChatSound`
+// changing, since we never mutate that value within the extension itself.
+// However, since we mutate `badgeCount` frequently, we should ensure that
+// these updates always run sync with each other and that the have access
+// to the most recent values.
class NotificationService: UNNotificationServiceExtension {
- var prefs = UserDefaults(suiteName: APP_GROUP)
+ private var contentHandler: ContentHandler?
+ private var bestAttempt: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
- guard let bestAttempt = createCopy(request.content),
+ self.contentHandler = contentHandler
+
+ guard let bestAttempt = NSEUtil.createCopy(request.content),
let reason = request.content.userInfo["reason"] as? String
else {
contentHandler(request.content)
return
}
+ self.bestAttempt = bestAttempt
if reason == "chat-message" {
mutateWithChatMessage(bestAttempt)
} else {
mutateWithBadge(bestAttempt)
}
+ // Any image downloading (or other network tasks) should be handled at the end
+ // of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire
+ // gets called, we might not have all the needed mutations completed in time.
+
contentHandler(bestAttempt)
}
override func serviceExtensionTimeWillExpire() {
- // If for some reason the alloted time expires, we don't actually want to display a notification
+ guard let contentHandler = self.contentHandler,
+ let bestAttempt = self.bestAttempt else {
+ return
+ }
+ contentHandler(bestAttempt)
}
- func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? {
- return content.mutableCopy() as? UNMutableNotificationContent
- }
+ // MARK: Mutations
func mutateWithBadge(_ content: UNMutableNotificationContent) {
- var count = prefs?.integer(forKey: "badgeCount") ?? 0
- count += 1
+ NSEUtil.shared.prefsQueue.sync {
+ var count = NSEUtil.shared.prefs?.integer(forKey: "badgeCount") ?? 0
+ count += 1
- // Set the new badge number for the notification, then store that value for using later
- content.badge = NSNumber(value: count)
- prefs?.setValue(count, forKey: "badgeCount")
+ // Set the new badge number for the notification, then store that value for using later
+ content.badge = NSNumber(value: count)
+ NSEUtil.shared.prefs?.setValue(count, forKey: "badgeCount")
+ }
}
func mutateWithChatMessage(_ content: UNMutableNotificationContent) {
- if self.prefs?.bool(forKey: "playSoundChat") == true {
+ if NSEUtil.shared.prefs?.bool(forKey: "playSoundChat") == true {
mutateWithDmSound(content)
}
}
@@ -54,3 +88,18 @@ class NotificationService: UNNotificationServiceExtension {
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "dm.aiff"))
}
}
+
+// NSEUtil's purpose is to create a shared instance of `UserDefaults` across
+// `NotificationService` instances. It also includes a queue so that we can process
+// updates to `UserDefaults` in parallel.
+
+private class NSEUtil {
+ static let shared = NSEUtil()
+
+ var prefs = UserDefaults(suiteName: APP_GROUP)
+ var prefsQueue = DispatchQueue(label: "NSEPrefsQueue")
+
+ static func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? {
+ return content.mutableCopy() as? UNMutableNotificationContent
+ }
+}
diff --git a/modules/Share-with-Bluesky/Info.plist b/modules/Share-with-Bluesky/Info.plist
index 421abb3c41..43f46a5e56 100644
--- a/modules/Share-with-Bluesky/Info.plist
+++ b/modules/Share-with-Bluesky/Info.plist
@@ -16,6 +16,8 @@
1
NSExtensionActivationSupportsImageWithMaxCount
10
+
NSExtensionActivationSupportsMovieWithMaxCount
+
1
NSExtensionPointIdentifier
@@ -38,4 +40,4 @@
CFBundleShortVersionString
$(MARKETING_VERSION)
-
\ No newline at end of file
+
diff --git a/modules/Share-with-Bluesky/ShareViewController.swift b/modules/Share-with-Bluesky/ShareViewController.swift
index c045d578fe..63143277a5 100644
--- a/modules/Share-with-Bluesky/ShareViewController.swift
+++ b/modules/Share-with-Bluesky/ShareViewController.swift
@@ -5,7 +5,6 @@ class ShareViewController: UIViewController {
// scheme.
let appScheme = Bundle.main.object(forInfoDictionaryKey: "MainAppScheme") as? String ?? "bluesky"
- //
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
@@ -24,6 +23,8 @@ class ShareViewController: UIViewController {
await self.handleUrl(item: firstAttachment)
} else if firstAttachment.hasItemConformingToTypeIdentifier("public.image") {
await self.handleImages(items: attachments)
+ } else if firstAttachment.hasItemConformingToTypeIdentifier("public.video") {
+ await self.handleVideos(items: attachments)
} else {
self.completeRequest()
}
@@ -31,31 +32,23 @@ class ShareViewController: UIViewController {
}
private func handleText(item: NSItemProvider) async {
- do {
- if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String {
- if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
- let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") {
- _ = self.openURL(url)
- }
+ if let data = try? await item.loadItem(forTypeIdentifier: "public.text") as? String {
+ if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
+ let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") {
+ _ = self.openURL(url)
}
- self.completeRequest()
- } catch {
- self.completeRequest()
}
+ self.completeRequest()
}
private func handleUrl(item: NSItemProvider) async {
- do {
- if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL {
- if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
- let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") {
- _ = self.openURL(url)
- }
+ if let data = try? await item.loadItem(forTypeIdentifier: "public.url") as? URL {
+ if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
+ let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") {
+ _ = self.openURL(url)
}
- self.completeRequest()
- } catch {
- self.completeRequest()
}
+ self.completeRequest()
}
private func handleImages(items: [NSItemProvider]) async {
@@ -105,6 +98,25 @@ class ShareViewController: UIViewController {
self.completeRequest()
}
+ private func handleVideos(items: [NSItemProvider]) async {
+ let firstItem = items.first
+
+ if let dataUri = try? await firstItem?.loadItem(forTypeIdentifier: "public.video") as? URL {
+ let ext = String(dataUri.lastPathComponent.split(separator: ".").last ?? "mp4")
+ if let tempUrl = getTempUrl(ext: ext) {
+ let data = try? Data(contentsOf: dataUri)
+ try? data?.write(to: tempUrl)
+
+ if let encoded = dataUri.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
+ let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(encoded)") {
+ _ = self.openURL(url)
+ }
+ }
+ }
+
+ self.completeRequest()
+ }
+
private func saveImageWithInfo(_ image: UIImage?) -> String? {
guard let image = image else {
return nil
@@ -114,27 +126,26 @@ class ShareViewController: UIViewController {
// Saving this file to the bundle group's directory lets us access it from
// inside of the app. Otherwise, we wouldn't have access even though the
// extension does.
- if let dir = FileManager()
- .containerURL(
- forSecurityApplicationGroupIdentifier: "group.app.bsky") {
- let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg"
-
- if let newUri = URL(string: filePath),
- let jpegData = image.jpegData(compressionQuality: 1) {
- try jpegData.write(to: newUri)
- return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)"
- }
+ if let tempUrl = getTempUrl(ext: "jpeg"),
+ let jpegData = image.jpegData(compressionQuality: 1) {
+ try jpegData.write(to: tempUrl)
+ return "\(tempUrl.absoluteString)|\(image.size.width)|\(image.size.height)"
}
- return nil
- } catch {
- return nil
- }
+ } catch {}
+ return nil
}
private func completeRequest() {
self.extensionContext?.completeRequest(returningItems: nil)
}
+ private func getTempUrl(ext: String) -> URL? {
+ if let dir = FileManager().containerURL(forSecurityApplicationGroupIdentifier: "group.app.bsky") {
+ return URL(string: "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).\(ext)")!
+ }
+ return nil
+ }
+
@objc func openURL(_ url: URL) -> Bool {
var responder: UIResponder? = self
while responder != nil {
diff --git a/package.json b/package.json
index 245c095f19..4b3486545e 100644
--- a/package.json
+++ b/package.json
@@ -49,11 +49,11 @@
"export": "npx expo export",
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 yarn build-web",
- "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
+ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web",
+ "icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.13.7",
- "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
@@ -160,24 +160,20 @@
"lodash.set": "^4.3.2",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
- "mobx": "^6.6.1",
- "mobx-react-lite": "^3.4.0",
- "mobx-utils": "^6.0.6",
"nanoid": "^5.0.5",
"normalize-url": "^8.0.0",
"patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"react": "18.2.0",
- "react-avatar-editor": "^13.0.0",
"react-compiler-runtime": "file:./lib/react-compiler-runtime",
"react-dom": "^18.2.0",
+ "react-image-crop": "^11.0.7",
"react-keyed-flatten-children": "^3.0.0",
"react-native": "0.74.1",
"react-native-compressor": "^1.8.24",
"react-native-date-picker": "^4.4.2",
"react-native-drawer-layout": "^4.0.0-alpha.3",
- "react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.16.2",
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "0.41.2",
@@ -238,7 +234,6 @@
"@types/lodash.set": "^4.3.7",
"@types/lodash.shuffle": "^4.2.7",
"@types/psl": "^1.1.1",
- "@types/react-avatar-editor": "^13.0.0",
"@types/react-dom": "^18.2.18",
"@types/react-responsive": "^8.0.5",
"@types/react-test-renderer": "^17.0.1",
@@ -272,6 +267,7 @@
"react-refresh": "^0.14.0",
"react-scripts": "^5.0.1",
"react-test-renderer": "18.2.0",
+ "svgo": "^3.3.2",
"ts-node": "^10.9.1",
"typescript": "^5.5.4",
"url-loader": "^4.1.1",
@@ -343,6 +339,9 @@
],
"*{.js,.jsx,.ts,.tsx,.css}": [
"prettier --cache --write --ignore-unknown"
+ ],
+ "assets/icons/*.svg": [
+ "svgo"
]
}
}
diff --git a/src/components/dialogs/Embed.tsx b/src/components/dialogs/Embed.tsx
index 73ecf6616b..ca75b01390 100644
--- a/src/components/dialogs/Embed.tsx
+++ b/src/components/dialogs/Embed.tsx
@@ -106,16 +106,18 @@ function EmbedDialogInner({
-
-
-
-
+
+
+
+
+
+
-
- {gallery.isEmpty && extLink && (
+
+ {images.length === 0 && extLink && (
) : (
-
+
-
+
)
-})
+}
export function useComposerCancelRef() {
return useRef(null)
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 4801ca0abf..f61d410dfc 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -26,7 +26,7 @@ export const ExternalEmbed = ({
title: link.meta?.title ?? link.uri,
uri: link.uri,
description: link.meta?.description ?? '',
- thumb: link.localThumb?.path,
+ thumb: link.localThumb?.source.path,
},
[link],
)
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
index a37452604f..a05607c76c 100644
--- a/src/view/com/composer/GifAltText.tsx
+++ b/src/view/com/composer/GifAltText.tsx
@@ -43,7 +43,7 @@ export function GifAltText({
title: linkProp.meta?.title ?? linkProp.uri,
uri: linkProp.uri,
description: linkProp.meta?.description ?? '',
- thumb: linkProp.localThumb?.path,
+ thumb: linkProp.localThumb?.source.path,
},
params: parseEmbedPlayerFromUrl(linkProp.uri),
}
diff --git a/src/view/com/composer/photos/EditImageDialog.tsx b/src/view/com/composer/photos/EditImageDialog.tsx
new file mode 100644
index 0000000000..4263587fd4
--- /dev/null
+++ b/src/view/com/composer/photos/EditImageDialog.tsx
@@ -0,0 +1,14 @@
+import React from 'react'
+
+import {ComposerImage} from '#/state/gallery'
+import * as Dialog from '#/components/Dialog'
+
+export type EditImageDialogProps = {
+ control: Dialog.DialogOuterProps['control']
+ image: ComposerImage
+ onChange: (next: ComposerImage) => void
+}
+
+export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
+ return null
+}
diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx
new file mode 100644
index 0000000000..0afb83ed96
--- /dev/null
+++ b/src/view/com/composer/photos/EditImageDialog.web.tsx
@@ -0,0 +1,105 @@
+import 'react-image-crop/dist/ReactCrop.css'
+
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import ReactCrop, {PercentCrop} from 'react-image-crop'
+
+import {
+ ImageSource,
+ ImageTransformation,
+ manipulateImage,
+} from '#/state/gallery'
+import {atoms as a} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Text} from '#/components/Typography'
+import {EditImageDialogProps} from './EditImageDialog'
+
+export const EditImageDialog = (props: EditImageDialogProps) => {
+ return (
+
+
+
+ )
+}
+
+const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
+ const {_} = useLingui()
+
+ const source = image.source
+
+ const initialCrop = getInitialCrop(source, image.manips)
+ const [crop, setCrop] = React.useState(initialCrop)
+
+ const isEmpty = !crop || (crop.width || crop.height) === 0
+ const isNew = initialCrop ? true : !isEmpty
+
+ const onPressSubmit = React.useCallback(async () => {
+ const result = await manipulateImage(image, {
+ crop:
+ crop && (crop.width || crop.height) !== 0
+ ? {
+ originX: (crop.x * source.width) / 100,
+ originY: (crop.y * source.height) / 100,
+ width: (crop.width * source.width) / 100,
+ height: (crop.height * source.height) / 100,
+ }
+ : undefined,
+ })
+
+ onChange(result)
+ control.close()
+ }, [crop, image, source, control, onChange])
+
+ return (
+
+
+
+
+ Edit image
+
+
+
+ setCrop(percentCrop)}
+ className="ReactCrop--no-animate">
+
+
+
+
+
+
+
+
+ )
+}
+
+const getInitialCrop = (
+ source: ImageSource,
+ manips: ImageTransformation | undefined,
+): PercentCrop | undefined => {
+ const initialArea = manips?.crop
+
+ if (initialArea) {
+ return {
+ unit: '%',
+ x: (initialArea.originX / source.width) * 100,
+ y: (initialArea.originY / source.height) * 100,
+ width: (initialArea.width / source.width) * 100,
+ height: (initialArea.height / source.height) * 100,
+ }
+ }
+}
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 422a4dd937..369f08d745 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -1,29 +1,38 @@
-import React, {useState} from 'react'
-import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import React from 'react'
+import {
+ ImageStyle,
+ Keyboard,
+ LayoutChangeEvent,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+ ViewStyle,
+} from 'react-native'
import {Image} from 'expo-image'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {observer} from 'mobx-react-lite'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {Dimensions} from '#/lib/media/types'
import {colors, s} from '#/lib/styles'
import {isNative} from '#/platform/detection'
-import {useModalControls} from '#/state/modals'
-import {GalleryModel} from '#/state/models/media/gallery'
+import {ComposerImage, cropImage} from '#/state/gallery'
import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {EditImageDialog} from './EditImageDialog'
+import {ImageAltTextDialog} from './ImageAltTextDialog'
const IMAGE_GAP = 8
interface GalleryProps {
- gallery: GalleryModel
+ images: ComposerImage[]
+ onChange: (next: ComposerImage[]) => void
}
-export const Gallery = (props: GalleryProps) => {
- const [containerInfo, setContainerInfo] = useState()
+export let Gallery = (props: GalleryProps): React.ReactNode => {
+ const [containerInfo, setContainerInfo] = React.useState()
const onLayout = (evt: LayoutChangeEvent) => {
const {width, height} = evt.nativeEvent.layout
@@ -41,177 +50,200 @@ export const Gallery = (props: GalleryProps) => {
)
}
+Gallery = React.memo(Gallery)
interface GalleryInnerProps extends GalleryProps {
containerInfo: Dimensions
}
-const GalleryInner = observer(function GalleryImpl({
- gallery,
- containerInfo,
-}: GalleryInnerProps) {
- const {_} = useLingui()
+const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => {
const {isMobile} = useWebMediaQueries()
- const {openModal} = useModalControls()
- const t = useTheme()
- let side: number
+ const {altTextControlStyle, imageControlsStyle, imageStyle} =
+ React.useMemo(() => {
+ const side =
+ images.length === 1
+ ? 250
+ : (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
+ images.length
- if (gallery.size === 1) {
- side = 250
- } else {
- side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size
- }
+ const isOverflow = isMobile && images.length > 2
- const imageStyle = {
- height: side,
- width: side,
- }
-
- const isOverflow = isMobile && gallery.size > 2
-
- const altTextControlStyle = isOverflow
- ? {
- left: 4,
- bottom: 4,
- }
- : !isMobile && gallery.size < 3
- ? {
- left: 8,
- top: 8,
- }
- : {
- left: 4,
- top: 4,
+ return {
+ altTextControlStyle: isOverflow
+ ? {left: 4, bottom: 4}
+ : !isMobile && images.length < 3
+ ? {left: 8, top: 8}
+ : {left: 4, top: 4},
+ imageControlsStyle: {
+ display: 'flex' as const,
+ flexDirection: 'row' as const,
+ position: 'absolute' as const,
+ ...(isOverflow
+ ? {top: 4, right: 4, gap: 4}
+ : !isMobile && images.length < 3
+ ? {top: 8, right: 8, gap: 8}
+ : {top: 4, right: 4, gap: 4}),
+ zIndex: 1,
+ },
+ imageStyle: {
+ height: side,
+ width: side,
+ },
}
+ }, [images.length, containerInfo, isMobile])
- const imageControlsStyle = {
- display: 'flex' as const,
- flexDirection: 'row' as const,
- position: 'absolute' as const,
- ...(isOverflow
- ? {
- top: 4,
- right: 4,
- gap: 4,
- }
- : !isMobile && gallery.size < 3
- ? {
- top: 8,
- right: 8,
- gap: 8,
- }
- : {
- top: 4,
- right: 4,
- gap: 4,
- }),
- zIndex: 1,
- }
-
- return !gallery.isEmpty ? (
+ return images.length !== 0 ? (
<>
- {gallery.images.map(image => (
-
- {
- Keyboard.dismiss()
- openModal({
- name: 'alt-text-image',
- image,
- })
+ {images.map((image, index) => {
+ return (
+ {
+ onChange(
+ images.map(i => (i.source === image.source ? next : i)),
+ )
}}
- style={[styles.altTextControl, altTextControlStyle]}>
- {image.altText.length > 0 ? (
-
- ) : (
-
- )}
-
- ALT
-
-
-
- {
- if (isNative) {
- gallery.crop(image)
- } else {
- openModal({
- name: 'edit-image',
- image,
- gallery,
- })
- }
- }}
- style={styles.imageControl}>
-
-
- gallery.remove(image)}
- style={styles.imageControl}>
-
-
-
- {
- Keyboard.dismiss()
- openModal({
- name: 'alt-text-image',
- image,
- })
- }}
- style={styles.altTextHiddenRegion}
- />
+ onRemove={() => {
+ const next = images.slice()
+ next.splice(index, 1)
-
-
- ))}
+ )
+ })}
>
) : null
-})
+}
+
+type GalleryItemProps = {
+ image: ComposerImage
+ altTextControlStyle?: ViewStyle
+ imageControlsStyle?: ViewStyle
+ imageStyle?: ViewStyle
+ onChange: (next: ComposerImage) => void
+ onRemove: () => void
+}
+
+const GalleryItem = ({
+ image,
+ altTextControlStyle,
+ imageControlsStyle,
+ imageStyle,
+ onChange,
+ onRemove,
+}: GalleryItemProps): React.ReactNode => {
+ const {_} = useLingui()
+ const t = useTheme()
+
+ const altTextControl = Dialog.useDialogControl()
+ const editControl = Dialog.useDialogControl()
+
+ const onImageEdit = () => {
+ if (isNative) {
+ cropImage(image).then(next => {
+ onChange(next)
+ })
+ } else {
+ editControl.open()
+ }
+ }
+
+ const onAltTextEdit = () => {
+ Keyboard.dismiss()
+ altTextControl.open()
+ }
+
+ return (
+
+
+ {image.alt.length !== 0 ? (
+
+ ) : (
+
+ )}
+
+ ALT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
export function AltTextReminder() {
const t = useTheme()
diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx
new file mode 100644
index 0000000000..123e1066a5
--- /dev/null
+++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx
@@ -0,0 +1,121 @@
+import React from 'react'
+import {ImageStyle, useWindowDimensions, View} from 'react-native'
+import {Image} from 'expo-image'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {MAX_ALT_TEXT} from '#/lib/constants'
+import {isWeb} from '#/platform/detection'
+import {ComposerImage} from '#/state/gallery'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import {Text} from '#/components/Typography'
+
+type Props = {
+ control: Dialog.DialogOuterProps['control']
+ image: ComposerImage
+ onChange: (next: ComposerImage) => void
+}
+
+export const ImageAltTextDialog = (props: Props): React.ReactNode => {
+ return (
+
+
+
+
+
+ )
+}
+
+const ImageAltTextInner = ({
+ control,
+ image,
+ onChange,
+}: Props): React.ReactNode => {
+ const {_} = useLingui()
+ const t = useTheme()
+
+ const windim = useWindowDimensions()
+
+ const [altText, setAltText] = React.useState(image.alt)
+
+ const onPressSubmit = React.useCallback(() => {
+ control.close()
+ onChange({...image, alt: altText.trim()})
+ }, [control, image, altText, onChange])
+
+ const imageStyle = React.useMemo
(() => {
+ const maxWidth = isWeb ? 450 : windim.width
+ const source = image.transformed ?? image.source
+
+ if (source.height > source.width) {
+ return {
+ resizeMode: 'contain',
+ width: '100%',
+ aspectRatio: 1,
+ borderRadius: 8,
+ }
+ }
+ return {
+ width: '100%',
+ height: (maxWidth / source.width) * source.height,
+ borderRadius: 8,
+ }
+ }, [image, windim])
+
+ return (
+
+
+
+
+
+ Add alt text
+
+
+
+
+
+
+
+
+
+
+ Descriptive alt text
+
+
+ setAltText(text)}
+ value={altText}
+ multiline
+ numberOfLines={3}
+ autoFocus
+ />
+
+
+
+
+
+ )
+}
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index f1f984103e..2183ca7902 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -9,17 +9,17 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions'
import {openCamera} from '#/lib/media/picker'
import {logger} from '#/logger'
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 {Button} from '#/components/Button'
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
type Props = {
- gallery: GalleryModel
disabled?: boolean
+ onAdd: (next: ComposerImage[]) => void
}
-export function OpenCameraBtn({gallery, disabled}: Props) {
+export function OpenCameraBtn({disabled, onAdd}: Props) {
const {track} = useAnalytics()
const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission()
@@ -48,13 +48,16 @@ export function OpenCameraBtn({gallery, disabled}: Props) {
if (mediaPermissionRes) {
await MediaLibrary.createAssetAsync(img.path)
}
- gallery.add(img)
+
+ const res = await createComposerImage(img)
+
+ onAdd([res])
} catch (err: any) {
// ignore
logger.warn('Error using camera', {error: err})
}
}, [
- gallery,
+ onAdd,
track,
requestCameraAccessIfNeeded,
mediaPermissionRes,
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index 747653fc8d..95d2df022c 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -5,18 +5,20 @@ import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
+import {openPicker} from '#/lib/media/picker'
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 {Button} from '#/components/Button'
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
type Props = {
- gallery: GalleryModel
+ size: number
disabled?: boolean
+ onAdd: (next: ComposerImage[]) => void
}
-export function SelectPhotoBtn({gallery, disabled}: Props) {
+export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
const {track} = useAnalytics()
const {_} = useLingui()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
@@ -29,8 +31,17 @@ export function SelectPhotoBtn({gallery, disabled}: Props) {
return
}
- gallery.pick()
- }, [track, requestPhotoAccessIfNeeded, gallery])
+ const images = await openPicker({
+ selectionLimit: 4 - size,
+ allowsMultipleSelection: true,
+ })
+
+ const results = await Promise.all(
+ images.map(img => createComposerImage(img)),
+ )
+
+ onAdd(results)
+ }, [track, requestPhotoAccessIfNeeded, size, onAdd])
return (