From dd1944e9b83da248fb50cdd1c10ca18b6c345b87 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 26 Sep 2024 19:57:16 -0700 Subject: [PATCH 01/10] [Share Extension] Support images/movies from other apps like iMessage (#5515) --- .../ShareViewController.swift | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/modules/Share-with-Bluesky/ShareViewController.swift b/modules/Share-with-Bluesky/ShareViewController.swift index 46851a0d79..2acbb6187b 100644 --- a/modules/Share-with-Bluesky/ShareViewController.swift +++ b/modules/Share-with-Bluesky/ShareViewController.swift @@ -1,5 +1,14 @@ import UIKit +let IMAGE_EXTENSIONS: [String] = ["png", "jpg", "jpeg", "gif", "heic"] +let MOVIE_EXTENSIONS: [String] = ["mov", "mp4", "m4v"] + +enum URLType: String, CaseIterable { + case image + case movie + case other +} + class ShareViewController: UIViewController { // This allows other forks to use this extension while also changing their // scheme. @@ -43,9 +52,18 @@ class ShareViewController: UIViewController { private func handleUrl(item: NSItemProvider) async { 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) + switch data.type { + case .image: + await handleImages(items: [item]) + return + case .movie: + await handleVideos(items: [item]) + return + case .other: + if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), + let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") { + _ = self.openURL(url) + } } } self.completeRequest() @@ -158,3 +176,21 @@ class ShareViewController: UIViewController { return false } } + +extension URL { + var type: URLType { + get { + guard self.absoluteString.starts(with: "file://"), + let ext = self.pathComponents.last?.split(separator: ".").last?.lowercased() else { + return .other + } + + if IMAGE_EXTENSIONS.contains(ext) { + return .image + } else if MOVIE_EXTENSIONS.contains(ext) { + return .movie + } + return .other + } + } +} From 389e6f15090ed35843f92253a299906df27cf993 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 26 Sep 2024 20:52:32 -0700 Subject: [PATCH 02/10] Lazy load ViewShot (#5517) * lazy one spot * lazy signup * fix type * tweak type, fix missing viewshot type * only import type oops --- src/components/StarterPack/QrCode.tsx | 17 ++-- src/components/StarterPack/QrCodeDialog.tsx | 90 ++++++++++--------- .../StepProfile/PlaceholderCanvas.tsx | 22 +++-- src/screens/Onboarding/StepProfile/index.tsx | 16 ++-- src/screens/Onboarding/state.ts | 14 +-- src/screens/Signup/StepInfo/index.tsx | 5 +- 6 files changed, 97 insertions(+), 67 deletions(-) diff --git a/src/components/StarterPack/QrCode.tsx b/src/components/StarterPack/QrCode.tsx index 8ce5cbbb13..c6408109bb 100644 --- a/src/components/StarterPack/QrCode.tsx +++ b/src/components/StarterPack/QrCode.tsx @@ -1,18 +1,23 @@ import React from 'react' import {View} from 'react-native' import QRCode from 'react-native-qrcode-styled' -import ViewShot from 'react-native-view-shot' +import type ViewShot from 'react-native-view-shot' import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' import {Trans} from '@lingui/macro' -import {isWeb} from 'platform/detection' -import {Logo} from 'view/icons/Logo' -import {Logotype} from 'view/icons/Logotype' +import {isWeb} from '#/platform/detection' +import {Logo} from '#/view/icons/Logo' +import {Logotype} from '#/view/icons/Logotype' import {useTheme} from '#/alf' import {atoms as a} from '#/alf' import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {Text} from '#/components/Typography' +const LazyViewShot = React.lazy( + // @ts-expect-error dynamic import + () => import('react-native-view-shot/src/index'), +) + interface Props { starterPack: AppBskyGraphDefs.StarterPackView link: string @@ -29,7 +34,7 @@ export const QrCode = React.forwardRef(function QrCode( } return ( - + (function QrCode( - + ) }) diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx index a884390bf8..b2af8ff73a 100644 --- a/src/components/StarterPack/QrCodeDialog.tsx +++ b/src/components/StarterPack/QrCodeDialog.tsx @@ -1,6 +1,6 @@ import React from 'react' import {View} from 'react-native' -import ViewShot from 'react-native-view-shot' +import type ViewShot from 'react-native-view-shot' import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' import {createAssetAsync} from 'expo-media-library' import * as Sharing from 'expo-sharing' @@ -8,9 +8,9 @@ import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' -import {logEvent} from 'lib/statsig/statsig' -import {isNative, isWeb} from 'platform/detection' +import {isNative, isWeb} from '#/platform/detection' import * as Toast from '#/view/com/util/Toast' import {atoms as a} from '#/alf' import {Button, ButtonText} from '#/components/Button' @@ -153,46 +153,54 @@ export function QrCodeDialog({ - {!link ? ( - - - - ) : ( - <> - - {isProcessing ? ( - - - - ) : ( - - - - - )} - - )} + }> + {!link ? ( + + ) : ( + <> + + {isProcessing ? ( + + + + ) : ( + + + + + )} + + )} + ) } + +function Loading() { + return ( + + + + ) +} diff --git a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx index d1d1af6d9f..eaad2113f8 100644 --- a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx +++ b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx @@ -1,14 +1,19 @@ import React from 'react' import {View} from 'react-native' -import ViewShot from 'react-native-view-shot' +import type ViewShot from 'react-native-view-shot' import {useAvatar} from '#/screens/Onboarding/StepProfile/index' import {atoms as a} from '#/alf' +const LazyViewShot = React.lazy( + // @ts-expect-error dynamic import + () => import('react-native-view-shot/src/index'), +) + const SIZE_MULTIPLIER = 5 export interface PlaceholderCanvasRef { - capture: () => Promise + capture: () => Promise } // This component is supposed to be invisible to the user. We only need this for ViewShot to have something to @@ -16,7 +21,7 @@ export interface PlaceholderCanvasRef { export const PlaceholderCanvas = React.forwardRef( function PlaceholderCanvas({}, ref) { const {avatar} = useAvatar() - const viewshotRef = React.useRef() + const viewshotRef = React.useRef(null) const Icon = avatar.placeholder.component const styles = React.useMemo( @@ -32,13 +37,16 @@ export const PlaceholderCanvas = React.forwardRef( ) React.useImperativeHandle(ref, () => ({ - // @ts-ignore this library doesn't have types - capture: viewshotRef.current.capture, + capture: async () => { + if (viewshotRef.current?.capture) { + return await viewshotRef.current.capture() + } + }, })) return ( - ( style={{color: 'white'}} /> - + ) }, diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 5304aa5031..79957da31a 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -10,13 +10,13 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useAnalytics} from '#/lib/analytics/analytics' +import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' +import {compressIfNeeded} from '#/lib/media/manip' +import {openCropper} from '#/lib/media/picker' +import {getDataUriSize} from '#/lib/media/util' +import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {logEvent, useGate} from '#/lib/statsig/statsig' -import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' -import {compressIfNeeded} from 'lib/media/manip' -import {openCropper} from 'lib/media/picker' -import {getDataUriSize} from 'lib/media/util' -import {useRequestNotificationsPermission} from 'lib/notifications/notifications' -import {isNative, isWeb} from 'platform/detection' +import {isNative, isWeb} from '#/platform/detection' import { DescriptionText, OnboardingControls, @@ -132,6 +132,10 @@ export function StepProfile() { const onContinue = React.useCallback(async () => { let imageUri = avatar?.image?.path + + // In the event that view-shot didn't load in time and the user pressed continue, this will just be undefined + // and the default avatar will be used. We don't want to block getting through create if this fails for some + // reason if (!imageUri || avatar.useCreatedAvatar) { imageUri = await canvasRef.current?.capture() } diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts index c41db5c3b7..70fa696408 100644 --- a/src/screens/Onboarding/state.ts +++ b/src/screens/Onboarding/state.ts @@ -51,13 +51,15 @@ export type OnboardingAction = | { type: 'setProfileStepResults' isCreatedAvatar: boolean - image?: OnboardingState['profileStepResults']['image'] - imageUri: string + image: OnboardingState['profileStepResults']['image'] | undefined + imageUri: string | undefined imageMime: string - creatorState?: { - emoji: Emoji - backgroundColor: AvatarColor - } + creatorState: + | { + emoji: Emoji + backgroundColor: AvatarColor + } + | undefined } export type ApiResponseMap = { diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx index 2cdb4b7224..d9b680602a 100644 --- a/src/screens/Signup/StepInfo/index.tsx +++ b/src/screens/Signup/StepInfo/index.tsx @@ -6,8 +6,8 @@ import * as EmailValidator from 'email-validator' import type tldts from 'tldts' import {logEvent} from '#/lib/statsig/statsig' +import {isEmailMaybeInvalid} from '#/lib/strings/email' import {logger} from '#/logger' -import {isEmailMaybeInvalid} from 'lib/strings/email' import {ScreenTransition} from '#/screens/Login/ScreenTransition' import {is13, is18, useSignupContext} from '#/screens/Signup/state' import {Policies} from '#/screens/Signup/StepInfo/Policies' @@ -59,6 +59,9 @@ export function StepInfo({ import('tldts/dist/index.cjs.min.js').then(tldts => { tldtsRef.current = tldts }) + // This will get used in the avatar creator a few steps later, so lets preload it now + // @ts-expect-error - valid path + import('react-native-view-shot/src/index') }, []) const onNextPress = () => { From c7b48cbdca7f5e5000cdffa0d3307fb2c3aba872 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 26 Sep 2024 21:01:57 -0700 Subject: [PATCH 03/10] Tweak font size of "Write your reply" (#5513) --- src/view/com/post-thread/PostThreadComposePrompt.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx index 7586bd9768..67981618e1 100644 --- a/src/view/com/post-thread/PostThreadComposePrompt.tsx +++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx @@ -63,11 +63,7 @@ export function PostThreadComposePrompt({ avatar={profile?.avatar} type={profile?.associated?.labeler ? 'labeler' : 'user'} /> - + Write your reply From dd2fedb2e68af57cac56b9019050af04119c7ff0 Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 27 Sep 2024 00:19:12 -0700 Subject: [PATCH 04/10] add podcasts to spotify embeds (#5514) --- src/lib/strings/embed-player.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts index 3bae771c0a..d0d8277c86 100644 --- a/src/lib/strings/embed-player.ts +++ b/src/lib/strings/embed-player.ts @@ -1,7 +1,7 @@ import {Dimensions} from 'react-native' -import {isSafari} from 'lib/browser' -import {isWeb} from 'platform/detection' +import {isSafari} from '#/lib/browser' +import {isWeb} from '#/platform/detection' const {height: SCREEN_HEIGHT} = Dimensions.get('window') @@ -185,6 +185,20 @@ export function parseEmbedPlayerFromUrl( playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`, } } + if (typeOrLocale === 'episode' || idOrType === 'episode') { + return { + type: 'spotify_song', + source: 'spotify', + playerUri: `https://open.spotify.com/embed/episode/${id ?? idOrType}`, + } + } + if (typeOrLocale === 'show' || idOrType === 'show') { + return { + type: 'spotify_song', + source: 'spotify', + playerUri: `https://open.spotify.com/embed/show/${id ?? idOrType}`, + } + } } } From 4553e6b64955c32225cefbe14117e4d08a0520ca Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 27 Sep 2024 10:09:00 +0100 Subject: [PATCH 05/10] Ignore bogus onScroll values (#5499) --- src/view/com/pager/PagerWithHeader.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 528f7fdf2e..6d601c2899 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -161,10 +161,17 @@ export const PagerWithHeader = React.forwardRef( (e: NativeScrollEvent) => { 'worklet' const nextScrollY = e.contentOffset.y - scrollY.value = nextScrollY - runOnJS(queueThrottledOnScroll)() + // HACK: onScroll is reporting some strange values on load (negative header height). + // Highly improbable that you'd be overscrolled by over 400px - + // in fact, I actually can't do it, so let's just ignore those. -sfn + const isPossiblyInvalid = + headerHeight > 0 && Math.round(nextScrollY * 2) / 2 === -headerHeight + if (!isPossiblyInvalid) { + scrollY.value = nextScrollY + runOnJS(queueThrottledOnScroll)() + } }, - [scrollY, queueThrottledOnScroll], + [scrollY, queueThrottledOnScroll, headerHeight], ) const onPageSelectedInner = React.useCallback( From d8f72c1ee10632860de9a67ce9c84831463ad07c Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 27 Sep 2024 09:54:37 -0700 Subject: [PATCH 06/10] [Share Extension] Support on Android for sharing videos to app (#5466) --- .../ExpoReceiveAndroidIntentsModule.kt | 100 ++++++++++++++---- plugins/shareExtension/withIntentFilters.js | 23 ++++ 2 files changed, 102 insertions(+), 21 deletions(-) diff --git a/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt b/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt index 7ecea16314..c88442057c 100644 --- a/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt +++ b/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt @@ -12,6 +12,11 @@ import java.io.File import java.io.FileOutputStream import java.net.URLEncoder +enum class AttachmentType { + IMAGE, + VIDEO, +} + class ExpoReceiveAndroidIntentsModule : Module() { override fun definition() = ModuleDefinition { @@ -23,17 +28,26 @@ class ExpoReceiveAndroidIntentsModule : Module() { } private fun handleIntent(intent: Intent?) { - if (appContext.currentActivity == null || intent == null) return - - if (intent.action == Intent.ACTION_SEND) { - if (intent.type == "text/plain") { - handleTextIntent(intent) - } else if (intent.type.toString().startsWith("image/")) { - handleImageIntent(intent) + if (appContext.currentActivity == null) return + intent?.let { + if (it.action == Intent.ACTION_SEND && it.type == "text/plain") { + handleTextIntent(it) + return } - } else if (intent.action == Intent.ACTION_SEND_MULTIPLE) { - if (intent.type.toString().startsWith("image/")) { - handleImagesIntent(intent) + + val type = + if (it.type.toString().startsWith("image/")) { + AttachmentType.IMAGE + } else if (it.type.toString().startsWith("video/")) { + AttachmentType.VIDEO + } else { + return + } + + if (it.action == Intent.ACTION_SEND) { + handleAttachmentIntent(it, type) + } else if (it.action == Intent.ACTION_SEND_MULTIPLE) { + handleAttachmentsIntent(it, type) } } } @@ -48,26 +62,46 @@ class ExpoReceiveAndroidIntentsModule : Module() { } } - private fun handleImageIntent(intent: Intent) { + private fun handleAttachmentIntent( + intent: Intent, + type: AttachmentType, + ) { val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) } else { intent.getParcelableExtra(Intent.EXTRA_STREAM) } - if (uri == null) return - handleImageIntents(listOf(uri)) + uri?.let { + when (type) { + AttachmentType.IMAGE -> handleImageIntents(listOf(it)) + AttachmentType.VIDEO -> handleVideoIntents(listOf(it)) + } + } } - private fun handleImagesIntent(intent: Intent) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.let { - handleImageIntents(it.filterIsInstance().take(4)) + private fun handleAttachmentsIntent( + intent: Intent, + type: AttachmentType, + ) { + val uris = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent + .getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + ?.filterIsInstance() + ?.take(4) + } else { + intent + .getParcelableArrayListExtra(Intent.EXTRA_STREAM) + ?.filterIsInstance() + ?.take(4) } - } else { - intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let { - handleImageIntents(it.filterIsInstance().take(4)) + + uris?.let { + when (type) { + AttachmentType.IMAGE -> handleImageIntents(it) + else -> return } } } @@ -93,11 +127,33 @@ class ExpoReceiveAndroidIntentsModule : Module() { } } + private fun handleVideoIntents(uris: List) { + val uri = uris[0] + // If there is no extension for the file, substringAfterLast returns the original string - not + // null, so we check for that below + // It doesn't actually matter what the extension is, so defaulting to mp4 is fine, even if the + // video isn't actually an mp4 + var extension = uri.path?.substringAfterLast(".") + if (extension == null || extension == uri.path) { + extension = "mp4" + } + val file = createFile(extension) + + val out = FileOutputStream(file) + appContext.currentActivity?.contentResolver?.openInputStream(uri)?.use { + it.copyTo(out) + } + "bluesky://intent/compose?videoUri=${URLEncoder.encode(file.path, "UTF-8")}".toUri().let { + val newIntent = Intent(Intent.ACTION_VIEW, it) + appContext.currentActivity?.startActivity(newIntent) + } + } + private fun getImageInfo(uri: Uri): Map { val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri) // We have to save this so that we can access it later when uploading the image. // createTempFile will automatically place a unique string between "img" and "temp.jpeg" - val file = File.createTempFile("img", "temp.jpeg", appContext.currentActivity?.cacheDir) + val file = createFile("jpeg") val out = FileOutputStream(file) bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out) out.flush() @@ -110,6 +166,8 @@ class ExpoReceiveAndroidIntentsModule : Module() { ) } + private fun createFile(extension: String): File = File.createTempFile(extension, "temp.$extension", appContext.currentActivity?.cacheDir) + // We will pas the width and height to the app here, since getting measurements // on the RN side is a bit more involved, and we already have them here anyway. private fun buildUriData(info: Map): String { diff --git a/plugins/shareExtension/withIntentFilters.js b/plugins/shareExtension/withIntentFilters.js index 605fcfd052..16494893bb 100644 --- a/plugins/shareExtension/withIntentFilters.js +++ b/plugins/shareExtension/withIntentFilters.js @@ -27,6 +27,29 @@ const withIntentFilters = config => { }, ], }, + { + action: [ + { + $: { + 'android:name': 'android.intent.action.SEND', + }, + }, + ], + category: [ + { + $: { + 'android:name': 'android.intent.category.DEFAULT', + }, + }, + ], + data: [ + { + $: { + 'android:mimeType': 'video/*', + }, + }, + ], + }, { action: [ { From bcd096b85aee45c38de7cfbcf1115b0a544589ae Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 27 Sep 2024 09:55:47 -0700 Subject: [PATCH 07/10] Fix alignment of cancel button on search (#5520) --- src/view/screens/Search/Search.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index de46d18c0c..583999f87e 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -845,14 +845,14 @@ export function SearchScreen( a.gap_sm, t.atoms.bg, web({ - height: headerHeight, + height: headerHeight + a.mb_sm.marginBottom, position: 'sticky', top: 0, zIndex: 1, }), ]} sideBorders={gtMobile}> - + {!gtMobile && (