+
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 066c7c7616..2cf8f5c240 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,8 @@
"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",
@@ -115,6 +116,7 @@
"deprecated-react-native-prop-types": "^5.0.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.5.2",
+ "emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "^52.0.0-canary-20240912-1059f85",
"expo-modules-core": "2.0.0-canary-20240912-1059f85",
@@ -159,24 +161,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.75.3",
"react-native-compressor": "^1.9.0",
"react-native-date-picker": "^4.4.2",
"react-native-drawer-layout": "^4.0.0-rc.10",
- "react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.19.0",
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "0.41.2",
@@ -237,7 +235,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",
@@ -271,6 +268,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",
@@ -340,8 +338,13 @@
},
"lint-staged": {
"*{.js,.jsx,.ts,.tsx}": [
- "eslint --cache --fix",
+ "eslint --cache --fix"
+ ],
+ "*{.js,.jsx,.ts,.tsx,.css}": [
"prettier --cache --write --ignore-unknown"
+ ],
+ "assets/icons/*.svg": [
+ "svgo"
]
}
}
diff --git a/patches/react-native+0.74.1.patch b/patches/react-native+0.74.1.patch
new file mode 100644
index 0000000000..f560175537
--- /dev/null
+++ b/patches/react-native+0.74.1.patch
@@ -0,0 +1,13 @@
+diff --git a/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm b/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm
+index caa5540..c5d4e67 100644
+--- a/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm
++++ b/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm
+@@ -73,7 +73,7 @@ @implementation RCTFileReaderModule
+ } else {
+ NSString *type = [RCTConvert NSString:blob[@"type"]];
+ NSString *text = [NSString stringWithFormat:@"data:%@;base64,%@",
+- type != nil && [type length] > 0 ? type : @"application/octet-stream",
++ ![type isEqual:[NSNull null]] && [type length] > 0 ? type : @"application/octet-stream",
+ [data base64EncodedStringWithOptions:0]];
+
+ resolve(text);
diff --git a/src/alf/fonts.ts b/src/alf/fonts.ts
index 08cfd9f42d..b11ce939f8 100644
--- a/src/alf/fonts.ts
+++ b/src/alf/fonts.ts
@@ -1,5 +1,3 @@
-import {useFonts as defaultUseFonts} from 'expo-font'
-
import {isWeb} from '#/platform/detection'
import {Device, device} from '#/storage'
@@ -34,39 +32,6 @@ export function setFontFamily(fontFamily: Device['fontFamily']) {
device.set(['fontFamily'], fontFamily)
}
-/*
- * IMPORTANT: This is unused. Expo statically extracts these fonts, but we load
- * them manually so that we can parallelize the loading along with the JS
- * bundle.
- *
- * See `#/alf/util/useFonts` for the actually used hooks.
- *
- * All used fonts MUST be configured here. Unused fonts are commented out, but
- * the files are there if we need them.
- */
-export function DO_NOT_USE() {
- return defaultUseFonts({
- // 'Inter-Thin': require('../../assets/fonts/inter/Inter-Thin.otf'),
- // 'Inter-ThinItalic': require('../../assets/fonts/inter/Inter-ThinItalic.otf'),
- // 'Inter-ExtraLight': require('../../assets/fonts/inter/Inter-ExtraLight.otf'),
- // 'Inter-ExtraLightItalic': require('../../assets/fonts/inter/Inter-ExtraLightItalic.otf'),
- // 'Inter-Light': require('../../assets/fonts/inter/Inter-Light.otf'),
- // 'Inter-LightItalic': require('../../assets/fonts/inter/Inter-LightItalic.otf'),
- 'Inter-Regular': require('../../assets/fonts/inter/Inter-Regular.otf'),
- 'Inter-Italic': require('../../assets/fonts/inter/Inter-Italic.otf'),
- // 'Inter-Medium': require('../../assets/fonts/inter/Inter-Medium.otf'),
- // 'Inter-MediumItalic': require('../../assets/fonts/inter/Inter-MediumItalic.otf'),
- 'Inter-SemiBold': require('../../assets/fonts/inter/Inter-SemiBold.otf'),
- 'Inter-SemiBoldItalic': require('../../assets/fonts/inter/Inter-SemiBoldItalic.otf'),
- // 'Inter-Bold': require('../../assets/fonts/inter/Inter-Bold.otf'),
- // 'Inter-BoldItalic': require('../../assets/fonts/inter/Inter-BoldItalic.otf'),
- 'Inter-ExtraBold': require('../../assets/fonts/inter/Inter-ExtraBold.otf'),
- 'Inter-ExtraBoldItalic': require('../../assets/fonts/inter/Inter-ExtraBoldItalic.otf'),
- // 'Inter-Black': require('../../assets/fonts/inter/Inter-Black.otf'),
- // 'Inter-BlackItalic': require('../../assets/fonts/inter/Inter-BlackItalic.otf'),
- })
-}
-
/*
* Unused fonts are commented out, but the files are there if we need them.
*/
diff --git a/src/alf/themes.ts b/src/alf/themes.ts
index f5d2247f9f..9f7ec5c673 100644
--- a/src/alf/themes.ts
+++ b/src/alf/themes.ts
@@ -183,7 +183,7 @@ export function createThemes({
} as const
const darkPalette: Palette = {
- white: color.gray_0,
+ white: color.gray_25,
black: color.trueBlack,
contrast_25: color.gray_975,
diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx
index e6d664cfda..b28f66f839 100644
--- a/src/components/FeedCard.tsx
+++ b/src/components/FeedCard.tsx
@@ -11,17 +11,17 @@ import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
+import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
+import {precacheFeedFromGeneratorView} from '#/state/queries/feed'
import {
useAddSavedFeedsMutation,
usePreferencesQuery,
useRemoveFeedMutation,
} from '#/state/queries/preferences'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {precacheFeedFromGeneratorView} from 'state/queries/feed'
-import {useSession} from 'state/session'
+import {useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
-import * as Toast from 'view/com/util/Toast'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
@@ -121,7 +121,10 @@ export function TitleAndByline({
return (
-
+
{title}
{creator && (
diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx
index 4017a7b0be..35a346c3a5 100644
--- a/src/components/KnownFollowers.tsx
+++ b/src/components/KnownFollowers.tsx
@@ -5,7 +5,7 @@ import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {makeProfileLink} from '#/lib/routes/links'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Link, LinkProps} from '#/components/Link'
@@ -185,11 +185,11 @@ function KnownFollowersInner({
serverCount > 2 ? (
Followed by{' '}
-
+
{slice[0].profile.displayName}
,{' '}
-
+
{slice[1].profile.displayName}
, and{' '}
@@ -203,11 +203,11 @@ function KnownFollowersInner({
// only 2
Followed by{' '}
-
+
{slice[0].profile.displayName}
{' '}
and{' '}
-
+
{slice[1].profile.displayName}
@@ -216,7 +216,7 @@ function KnownFollowersInner({
// 1-n followers, including blocks
Followed by{' '}
-
+
{slice[0].profile.displayName}
{' '}
and{' '}
@@ -230,7 +230,7 @@ function KnownFollowersInner({
// only 1
Followed by{' '}
-
+
{slice[0].profile.displayName}
diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx
index 851645a48c..03b8ece6b1 100644
--- a/src/components/LabelingServiceCard/index.tsx
+++ b/src/components/LabelingServiceCard/index.tsx
@@ -44,17 +44,22 @@ export function Avatar({avatar}: {avatar?: string}) {
}
export function Title({value}: {value: string}) {
- return {value}
+ return (
+
+ {value}
+
+ )
}
export function Description({value, handle}: {value?: string; handle: string}) {
+ const {_} = useLingui()
return value ? (
) : (
-
- By {sanitizeHandle(handle, '@')}
+
+ {_(msg`By ${sanitizeHandle(handle, '@')}`)}
)
}
diff --git a/src/components/ListCard.tsx b/src/components/ListCard.tsx
index 829f36d471..ed5838fb04 100644
--- a/src/components/ListCard.tsx
+++ b/src/components/ListCard.tsx
@@ -7,13 +7,14 @@ import {
moderateUserList,
ModerationUI,
} from '@atproto/api'
-import {Trans} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {useModerationOpts} from 'state/preferences/moderation-opts'
-import {precacheList} from 'state/queries/feed'
-import {useSession} from 'state/session'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {precacheList} from '#/state/queries/feed'
+import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {
Avatar,
@@ -111,6 +112,7 @@ export function TitleAndByline({
modUi?: ModerationUI
}) {
const t = useTheme()
+ const {_} = useLingui()
const {currentAccount} = useSession()
return (
@@ -130,6 +132,7 @@ export function TitleAndByline({
{title}
@@ -139,15 +142,12 @@ export function TitleAndByline({
{creator && (
- {purpose === MODLIST ? (
-
- Moderation list by {sanitizeHandle(creator.handle, '@')}
-
- ) : (
- List by {sanitizeHandle(creator.handle, '@')}
- )}
+ {purpose === MODLIST
+ ? _(msg`Moderation list by ${sanitizeHandle(creator.handle, '@')}`)
+ : _(msg`List by ${sanitizeHandle(creator.handle, '@')}`)}
)}
diff --git a/src/components/Pills.tsx b/src/components/Pills.tsx
index 6c8084743f..974d83593f 100644
--- a/src/components/Pills.tsx
+++ b/src/components/Pills.tsx
@@ -130,6 +130,7 @@ export function Label({
)}
{name}
{handle}
diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx
index 2def0fa4b4..e323d15042 100644
--- a/src/components/ReportDialog/SubmitView.tsx
+++ b/src/components/ReportDialog/SubmitView.tsx
@@ -256,6 +256,7 @@ function LabelerToggle({title}: {title: string}) {
a.z_10,
]}>
,
)
} else {
- els.push(segment.text)
+ els.push(
+
+ {segment.text}
+ ,
+ )
}
key++
}
@@ -213,6 +219,7 @@ function RichTextTag({
{!noIcon ? : null}
-
+
{record.name}
-
-
- Starter pack by{' '}
- {creator?.did === currentAccount?.did
- ? _(msg`you`)
- : `@${sanitizeHandle(creator.handle)}`}
-
+
+ {creator?.did === currentAccount?.did
+ ? _(msg`Starter pack by you`)
+ : _(msg`Starter pack by ${sanitizeHandle(creator.handle, '@')}`)}
{!noDescription && record.description ? (
-
+
{record.description}
) : null}
diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx
index ad02cdc306..44f01a1545 100644
--- a/src/components/StarterPack/Wizard/WizardListCard.tsx
+++ b/src/components/StarterPack/Wizard/WizardListCard.tsx
@@ -12,11 +12,11 @@ import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from 'lib/constants'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {useSession} from 'state/session'
-import {UserAvatar} from 'view/com/util/UserAvatar'
+import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from '#/lib/constants'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {useSession} from '#/state/session'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
@@ -78,6 +78,7 @@ function WizardListCard({
/>
& {
/**
* Lets the user select text, to use the native copy and paste functionality.
*/
selectable?: boolean
+ /**
+ * Provides `data-*` attributes to the underlying `UITextView` component on
+ * web only.
+ */
+ dataSet?: Record
+ /**
+ * Appears as a small tooltip on web hover.
+ */
+ title?: string
+} & (
+ | {
+ emoji: true
+ children: StringChild
+ }
+ | {
+ emoji?: false
+ children: RNTextProps['children']
+ }
+ )
+
+const EMOJI = createEmojiRegex()
+
+export function childHasEmoji(children: React.ReactNode) {
+ return (Array.isArray(children) ? children : [children]).some(
+ child => typeof child === 'string' && createEmojiRegex().test(child),
+ )
+}
+
+export function childIsString(
+ children: React.ReactNode,
+): children is StringChild {
+ return (
+ typeof children === 'string' ||
+ (Array.isArray(children) &&
+ children.every(child => typeof child === 'string' || child === null))
+ )
+}
+
+export function renderChildrenWithEmoji(children: StringChild) {
+ const normalized = Array.isArray(children) ? children : [children]
+
+ return (
+
+ {normalized.map(child => {
+ if (typeof child !== 'string') return child
+
+ const emojis = child.match(EMOJI)
+
+ if (emojis === null) {
+ return child
+ }
+
+ return child.split(EMOJI).map((stringPart, index) => (
+
+ {stringPart}
+ {emojis[index] ? (
+
+ {emojis[index]}
+
+ ) : null}
+
+ ))
+ })}
+
+ )
}
/**
@@ -64,7 +134,15 @@ export function normalizeTextStyles(
/**
* Our main text component. Use this most of the time.
*/
-export function Text({style, selectable, ...rest}: TextProps) {
+export function Text({
+ children,
+ emoji,
+ style,
+ selectable,
+ title,
+ dataSet,
+ ...rest
+}: TextProps) {
const {fonts, flags} = useAlf()
const t = useTheme()
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)], {
@@ -73,7 +151,29 @@ export function Text({style, selectable, ...rest}: TextProps) {
flags,
})
- return
+ if (IS_DEV) {
+ if (!emoji && childHasEmoji(children)) {
+ logger.warn(
+ `Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add '`,
+ )
+ }
+
+ if (emoji && !childIsString(children)) {
+ logger.error('Text: when , children can only be strings.')
+ }
+ }
+
+ return (
+
+ {isIOS && emoji ? renderChildrenWithEmoji(children) : children}
+
+ )
}
export function createHeadingElement({level}: {level: number}) {
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({
-
-
-
-
+
+
+
+
+
+
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index dfdfb3ebdf..3b7cf13851 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -44,7 +44,6 @@ import {RichText} from '@atproto/api'
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 {useAnalytics} from '#/lib/analytics/analytics'
import * as apilib from '#/lib/api/index'
@@ -68,9 +67,9 @@ import {logger} from '#/logger'
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
import {useDialogStateControlContext} from '#/state/dialogs'
import {emitPostCreated} from '#/state/events'
+import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery'
import {useModalControls} from '#/state/modals'
import {useModals} from '#/state/modals'
-import {GalleryModel} from '#/state/models/media/gallery'
import {useRequireAltTextEnabled} from '#/state/preferences'
import {
toPostLanguages,
@@ -122,12 +121,14 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography'
+const MAX_IMAGES = 4
+
type CancelRef = {
onPressCancel: () => void
}
type Props = ComposerOpts
-export const ComposePost = observer(function ComposePost({
+export const ComposePost = ({
replyTo,
onPost,
quote: initQuote,
@@ -139,7 +140,7 @@ export const ComposePost = observer(function ComposePost({
cancelRef,
}: Props & {
cancelRef?: React.RefObject
-}) {
+}) => {
const {currentAccount} = useSession()
const agent = useAgent()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@@ -212,9 +213,8 @@ export const ComposePost = observer(function ComposePost({
)
const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
- const gallery = useMemo(
- () => new GalleryModel(initImageUris),
- [initImageUris],
+ const [images, setImages] = useState(() =>
+ createInitialImages(initImageUris),
)
const onClose = useCallback(() => {
closeComposer()
@@ -233,7 +233,7 @@ export const ComposePost = observer(function ComposePost({
const onPressCancel = useCallback(() => {
if (
graphemeLength > 0 ||
- !gallery.isEmpty ||
+ images.length !== 0 ||
extGif ||
videoUploadState.status !== 'idle'
) {
@@ -246,7 +246,7 @@ export const ComposePost = observer(function ComposePost({
}, [
extGif,
graphemeLength,
- gallery.isEmpty,
+ images.length,
closeAllDialogs,
discardPromptControl,
onClose,
@@ -299,22 +299,31 @@ export const ComposePost = observer(function ComposePost({
[extLink, setExtLink],
)
+ const onImageAdd = useCallback(
+ (next: ComposerImage[]) => {
+ setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length)))
+ },
+ [setImages],
+ )
+
const onPhotoPasted = useCallback(
async (uri: string) => {
track('Composer:PastedPhotos')
if (uri.startsWith('data:video/')) {
selectVideo({uri, type: 'video', height: 0, width: 0})
} else {
- await gallery.paste(uri)
+ const res = await pasteImage(uri)
+ onImageAdd([res])
}
},
- [gallery, track, selectVideo],
+ [track, selectVideo, onImageAdd],
)
const isAltTextRequiredAndMissing = useMemo(() => {
if (!requireAltTextEnabled) return false
- if (gallery.needsAltText) return true
+ if (images.some(img => img.alt === '')) return true
+
if (extGif) {
if (!extLink?.meta?.description) return true
@@ -322,7 +331,7 @@ export const ComposePost = observer(function ComposePost({
if (!parsedAlt.isPreferred) return true
}
return false
- }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
+ }, [images, extLink, extGif, requireAltTextEnabled])
const onPressPublish = React.useCallback(
async (finishedUploading?: boolean) => {
@@ -347,7 +356,7 @@ export const ComposePost = observer(function ComposePost({
if (
richtext.text.trim().length === 0 &&
- gallery.isEmpty &&
+ images.length === 0 &&
!extLink &&
!quote &&
videoUploadState.status === 'idle'
@@ -368,7 +377,7 @@ export const ComposePost = observer(function ComposePost({
await apilib.post(agent, {
rawText: richtext.text,
replyTo: replyTo?.uri,
- images: gallery.images,
+ images,
quote,
extLink,
labels,
@@ -405,7 +414,7 @@ export const ComposePost = observer(function ComposePost({
} catch (e: any) {
logger.error(e, {
message: `Composer: create post failed`,
- hasImages: gallery.size > 0,
+ hasImages: images.length > 0,
})
if (extLink) {
@@ -427,7 +436,7 @@ export const ComposePost = observer(function ComposePost({
} finally {
if (postUri) {
logEvent('post:create', {
- imageCount: gallery.size,
+ imageCount: images.length,
isReply: replyTo != null,
hasLink: extLink != null,
hasQuote: quote != null,
@@ -436,7 +445,7 @@ export const ComposePost = observer(function ComposePost({
})
}
track('Create Post', {
- imageCount: gallery.size,
+ imageCount: images.length,
})
if (replyTo && replyTo.uri) track('Post:Reply')
}
@@ -472,9 +481,7 @@ export const ComposePost = observer(function ComposePost({
agent,
captions,
extLink,
- gallery.images,
- gallery.isEmpty,
- gallery.size,
+ images,
graphemeLength,
isAltTextRequiredAndMissing,
isProcessing,
@@ -516,12 +523,12 @@ export const ComposePost = observer(function ComposePost({
: _(msg`What's up?`)
const canSelectImages =
- gallery.size < 4 &&
+ images.length < MAX_IMAGES &&
!extLink &&
videoUploadState.status === 'idle' &&
!videoUploadState.video
const hasMedia =
- gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
+ images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
const onEmojiButtonPress = useCallback(() => {
openEmojiPicker?.(textInput.current?.getCursorPosition())
@@ -716,8 +723,8 @@ export const ComposePost = observer(function ComposePost({
/>
-
- {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..f48e50cfd7 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -1,10 +1,10 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
-import {ExternalEmbedDraft} from 'lib/api/index'
-import {Gif} from 'state/queries/tenor'
-import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
-import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed'
+import {ExternalEmbedDraft} from '#/lib/api/index'
+import {Gif} from '#/state/queries/tenor'
+import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
+import {ExternalLinkEmbed} from '#/view/com/util/post-embeds/ExternalLinkEmbed'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
@@ -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 (