Merge branch 'bluesky-social:main' into patch-3

zio/stable
Minseo Lee 2024-02-28 13:03:55 +09:00 committed by GitHub
commit 3767e76390
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 4654 additions and 1462 deletions

View File

@ -49,10 +49,14 @@ jobs:
- name: ⚙️ Install dependencies - name: ⚙️ Install dependencies
run: yarn install run: yarn install
- name: 🔤 Compile translations
run: yarn intl:build
- name: ✏️ Write environment variables - name: ✏️ Write environment variables
run: | run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env echo "${{ secrets.ENV_TOKEN }}" > .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json echo "$json" > google-services.json
- name: 🏗️ EAS Build - name: 🏗️ EAS Build
run: yarn use-build-number eas build -p android --profile production --local --output build.aab --non-interactive run: yarn use-build-number eas build -p android --profile production --local --output build.aab --non-interactive

View File

@ -60,6 +60,9 @@ jobs:
# change unless the yarn version changes as well. # change unless the yarn version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }} key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
- name: 🔤 Compile translations
run: yarn intl:build
- name: ✏️ Write environment variables - name: ✏️ Write environment variables
run: | run: |
echo "${{ secrets.ENV_TOKEN }}" > .env echo "${{ secrets.ENV_TOKEN }}" > .env

View File

@ -89,6 +89,10 @@ module.exports = function (config) {
scheme: 'https', scheme: 'https',
host: 'bsky.app', host: 'bsky.app',
}, },
{
scheme: 'http',
host: 'localhost:19006',
},
], ],
category: ['BROWSABLE', 'DEFAULT'], category: ['BROWSABLE', 'DEFAULT'],
}, },
@ -137,6 +141,7 @@ module.exports = function (config) {
}, },
], ],
'./plugins/withAndroidManifestPlugin.js', './plugins/withAndroidManifestPlugin.js',
'./plugins/shareExtension/withShareExtensions.js',
].filter(Boolean), ].filter(Boolean),
extra: { extra: {
eas: { eas: {

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>10</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
<key>MainAppScheme</key>
<string>bluesky</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key>
<string>Extension</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
</dict>
</plist>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.bsky</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,153 @@
import UIKit
class ShareViewController: UIViewController {
// This allows other forks to use this extension while also changing their
// scheme.
let appScheme = Bundle.main.object(forInfoDictionaryKey: "MainAppScheme") as? String ?? "bluesky"
//
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
let attachments = extensionItem.attachments,
let firstAttachment = extensionItem.attachments?.first
else {
self.completeRequest()
return
}
Task {
if firstAttachment.hasItemConformingToTypeIdentifier("public.text") {
await self.handleText(item: firstAttachment)
} else if firstAttachment.hasItemConformingToTypeIdentifier("public.url") {
await self.handleUrl(item: firstAttachment)
} else if firstAttachment.hasItemConformingToTypeIdentifier("public.image") {
await self.handleImages(items: attachments)
} else {
self.completeRequest()
}
}
}
private func handleText(item: NSItemProvider) async -> Void {
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)
}
}
self.completeRequest()
} catch {
self.completeRequest()
}
}
private func handleUrl(item: NSItemProvider) async -> Void {
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)
}
}
self.completeRequest()
} catch {
self.completeRequest()
}
}
private func handleImages(items: [NSItemProvider]) async -> Void {
let firstFourItems: [NSItemProvider]
if items.count < 4 {
firstFourItems = items
} else {
firstFourItems = Array(items[0...3])
}
var valid = true
var imageUris = ""
for (index, item) in firstFourItems.enumerated() {
var imageUriInfo: String? = nil
do {
if let dataUri = try await item.loadItem(forTypeIdentifier: "public.image") as? URL {
// We need to duplicate this image, since we don't have access to the outgoing temp directory
// We also will get the image dimensions here, sinze RN makes it difficult to get those dimensions for local files
let data = try Data(contentsOf: dataUri)
let image = UIImage(data: data)
imageUriInfo = self.saveImageWithInfo(image)
} else if let image = try await item.loadItem(forTypeIdentifier: "public.image") as? UIImage {
imageUriInfo = self.saveImageWithInfo(image)
}
} catch {
valid = false
}
if let imageUriInfo = imageUriInfo {
imageUris.append(imageUriInfo)
if index < items.count - 1 {
imageUris.append(",")
}
} else {
valid = false
}
}
if valid,
let encoded = imageUris.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
let url = URL(string: "\(self.appScheme)://intent/compose?imageUris=\(encoded)")
{
_ = self.openURL(url)
}
self.completeRequest()
}
private func saveImageWithInfo(_ image: UIImage?) -> String? {
guard let image = image else {
return nil
}
do {
// 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.\(Bundle.main.bundleIdentifier?.replacingOccurrences(of: ".Share-with-Bluesky", with: "") ?? "")")
{
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)"
}
}
return nil
} catch {
return nil
}
}
private func completeRequest() -> Void {
self.extensionContext?.completeRequest(returningItems: nil)
}
@objc func openURL(_ url: URL) -> Bool {
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
return application.perform(#selector(openURL(_:)), with: url) != nil
}
responder = responder?.next
}
return false
}
}

View File

@ -0,0 +1,8 @@
# Expo Receive Android Intents
This module handles incoming intents on Android. Handled intents are `text/plain` and `image/*` (single or multiple).
The module handles saving images to the app's filesystem for access within the app, limiting the selection of images
to a max of four, and handling intent types. No JS code is required for this module, and it is no-op on non-android
platforms.
No installation is required. Gradle will automatically add this module on build.

View File

@ -0,0 +1,15 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
# Bundle artifacts
*.jsbundle

View File

@ -0,0 +1,92 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'maven-publish'
group = 'xyz.blueskyweb.app.exporeceiveandroidintents'
version = '0.4.1'
buildscript {
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
if (expoModulesCorePlugin.exists()) {
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
}
// Simple helper that allows the root project to override versions declared by this library.
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
// Ensures backward compatibility
ext.getKotlinVersion = {
if (ext.has("kotlinVersion")) {
ext.kotlinVersion()
} else {
ext.safeExtGet("kotlinVersion", "1.8.10")
}
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
}
}
afterEvaluate {
publishing {
publications {
release(MavenPublication) {
from components.release
}
}
repositories {
maven {
url = mavenLocal().url
}
}
}
}
android {
compileSdkVersion safeExtGet("compileSdkVersion", 33)
def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
if (agpVersion.tokenize('.')[0].toInteger() < 8) {
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.majorVersion
}
}
namespace "xyz.blueskyweb.app.exporeceiveandroidintents"
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 21)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
versionCode 1
versionName "0.4.1"
}
lintOptions {
abortOnError false
}
publishing {
singleVariant("release") {
withSourcesJar()
}
}
}
repositories {
mavenCentral()
}
dependencies {
implementation project(':expo-modules-core')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
}

View File

@ -0,0 +1,2 @@
<manifest>
</manifest>

View File

@ -0,0 +1,119 @@
package xyz.blueskyweb.app.exporeceiveandroidintents
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.core.net.toUri
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
import java.io.FileOutputStream
import java.net.URLEncoder
class ExpoReceiveAndroidIntentsModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoReceiveAndroidIntents")
OnNewIntent {
handleIntent(it)
}
}
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)
}
} else if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
if (intent.type.toString().startsWith("image/")) {
handleImagesIntent(intent)
}
}
}
private fun handleTextIntent(intent: Intent) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
val encoded = URLEncoder.encode(it, "UTF-8")
"bluesky://intent/compose?text=${encoded}".toUri().let { uri ->
val newIntent = Intent(Intent.ACTION_VIEW, uri)
appContext.currentActivity?.startActivity(newIntent)
}
}
}
private fun handleImageIntent(intent: Intent) {
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))
}
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<Uri>().take(4))
}
} else {
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let {
handleImageIntents(it.filterIsInstance<Uri>().take(4))
}
}
}
private fun handleImageIntents(uris: List<Uri>) {
var allParams = ""
uris.forEachIndexed { index, uri ->
val info = getImageInfo(uri)
val params = buildUriData(info)
allParams = "${allParams}${params}"
if (index < uris.count() - 1) {
allParams = "${allParams},"
}
}
val encoded = URLEncoder.encode(allParams, "UTF-8")
"bluesky://intent/compose?imageUris=${encoded}".toUri().let {
val newIntent = Intent(Intent.ACTION_VIEW, it)
appContext.currentActivity?.startActivity(newIntent)
}
}
private fun getImageInfo(uri: Uri): Map<String, Any> {
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 out = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
out.flush()
out.close()
return mapOf(
"width" to bitmap.width,
"height" to bitmap.height,
"path" to file.path.toString()
)
}
// 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, Any>): String {
val path = info.getValue("path")
val width = info.getValue("width")
val height = info.getValue("height")
return "file://${path}|${width}|${height}"
}
}

View File

@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["xyz.blueskyweb.app.exporeceiveandroidintents.ExpoReceiveAndroidIntentsModule"]
}
}

View File

@ -36,11 +36,12 @@
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json", "perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json", "perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
"perf:measure": "NODE_ENV=test flashlight measure", "perf:measure": "NODE_ENV=test flashlight measure",
"intl:build": "yarn intl:check && yarn intl:compile", "intl:build": "yarn intl:extract && yarn intl:compile",
"intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠ i18n detected un-extracted translations\n' && exit 1)", "intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠ i18n detected un-extracted translations\n' && exit 1)",
"intl:extract": "lingui extract", "intl:extract": "lingui extract",
"intl:compile": "lingui compile", "intl:compile": "lingui compile",
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android" "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
"update-extensions": "scripts/updateExtensions.sh"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.10.0", "@atproto/api": "^0.10.0",
@ -109,6 +110,7 @@
"expo-image": "~1.10.3", "expo-image": "~1.10.3",
"expo-image-manipulator": "^11.8.0", "expo-image-manipulator": "^11.8.0",
"expo-image-picker": "~14.7.1", "expo-image-picker": "~14.7.1",
"expo-linking": "^6.2.2",
"expo-localization": "~14.8.2", "expo-localization": "~14.8.2",
"expo-media-library": "~15.9.1", "expo-media-library": "~15.9.1",
"expo-notifications": "~0.27.3", "expo-notifications": "~0.27.3",

View File

@ -0,0 +1,22 @@
# Share extension plugin for Expo
This plugin handles moving the necessary files into their respective iOS and Android targets and updating the build
phases, plists, manifests, etc.
## Steps
### ios
1. Update entitlements
2. Set the app group to group.<identifier>
3. Add the extension plist
4. Add the view controller
5. Update the xcode project's build phases
### android
1. Update the manifest with the intents the app can receive
## Credits
Adapted from https://github.com/andrew-levy/react-native-safari-extension and https://github.com/timedtext/expo-config-plugin-ios-share-extension/blob/master/src/withShareExtensionXcodeTarget.ts

View File

@ -0,0 +1,13 @@
const {withEntitlementsPlist} = require('@expo/config-plugins')
const withAppEntitlements = config => {
// eslint-disable-next-line no-shadow
return withEntitlementsPlist(config, async config => {
config.modResults['com.apple.security.application-groups'] = [
`group.app.bsky`,
]
return config
})
}
module.exports = {withAppEntitlements}

View File

@ -0,0 +1,31 @@
const {withInfoPlist} = require('@expo/config-plugins')
const plist = require('@expo/plist')
const path = require('path')
const fs = require('fs')
const withExtensionEntitlements = (config, {extensionName}) => {
// eslint-disable-next-line no-shadow
return withInfoPlist(config, config => {
const extensionEntitlementsPath = path.join(
config.modRequest.platformProjectRoot,
extensionName,
`${extensionName}.entitlements`,
)
const shareExtensionEntitlements = {
'com.apple.security.application-groups': [`group.app.bsky`],
}
fs.mkdirSync(path.dirname(extensionEntitlementsPath), {
recursive: true,
})
fs.writeFileSync(
extensionEntitlementsPath,
plist.default.build(shareExtensionEntitlements),
)
return config
})
}
module.exports = {withExtensionEntitlements}

View File

@ -0,0 +1,39 @@
const {withInfoPlist} = require('@expo/config-plugins')
const plist = require('@expo/plist')
const path = require('path')
const fs = require('fs')
const withExtensionInfoPlist = (config, {extensionName}) => {
// eslint-disable-next-line no-shadow
return withInfoPlist(config, config => {
const plistPath = path.join(
config.modRequest.projectRoot,
'modules',
extensionName,
'Info.plist',
)
const targetPath = path.join(
config.modRequest.platformProjectRoot,
extensionName,
'Info.plist',
)
const extPlist = plist.default.parse(fs.readFileSync(plistPath).toString())
extPlist.MainAppScheme = config.scheme
extPlist.CFBundleName = '$(PRODUCT_NAME)'
extPlist.CFBundleDisplayName = 'Extension'
extPlist.CFBundleIdentifier = '$(PRODUCT_BUNDLE_IDENTIFIER)'
extPlist.CFBundleVersion = '$(CURRENT_PROJECT_VERSION)'
extPlist.CFBundleExecutable = '$(EXECUTABLE_NAME)'
extPlist.CFBundlePackageType = '$(PRODUCT_BUNDLE_PACKAGE_TYPE)'
extPlist.CFBundleShortVersionString = '$(MARKETING_VERSION)'
fs.mkdirSync(path.dirname(targetPath), {recursive: true})
fs.writeFileSync(targetPath, plist.default.build(extPlist))
return config
})
}
module.exports = {withExtensionInfoPlist}

View File

@ -0,0 +1,31 @@
const {withXcodeProject} = require('@expo/config-plugins')
const path = require('path')
const fs = require('fs')
const withExtensionViewController = (
config,
{controllerName, extensionName},
) => {
// eslint-disable-next-line no-shadow
return withXcodeProject(config, config => {
const controllerPath = path.join(
config.modRequest.projectRoot,
'modules',
extensionName,
`${controllerName}.swift`,
)
const targetPath = path.join(
config.modRequest.platformProjectRoot,
extensionName,
`${controllerName}.swift`,
)
fs.mkdirSync(path.dirname(targetPath), {recursive: true})
fs.copyFileSync(controllerPath, targetPath)
return config
})
}
module.exports = {withExtensionViewController}

View File

@ -0,0 +1,89 @@
const {withAndroidManifest} = require('@expo/config-plugins')
const withIntentFilters = config => {
// eslint-disable-next-line no-shadow
return withAndroidManifest(config, config => {
const intents = [
{
action: [
{
$: {
'android:name': 'android.intent.action.SEND',
},
},
],
category: [
{
$: {
'android:name': 'android.intent.category.DEFAULT',
},
},
],
data: [
{
$: {
'android:mimeType': 'image/*',
},
},
],
},
{
action: [
{
$: {
'android:name': 'android.intent.action.SEND',
},
},
],
category: [
{
$: {
'android:name': 'android.intent.category.DEFAULT',
},
},
],
data: [
{
$: {
'android:mimeType': 'text/plain',
},
},
],
},
{
action: [
{
$: {
'android:name': 'android.intent.action.SEND_MULTIPLE',
},
},
],
category: [
{
$: {
'android:name': 'android.intent.category.DEFAULT',
},
},
],
data: [
{
$: {
'android:mimeType': 'image/*',
},
},
],
},
]
const intentFilter =
config.modResults.manifest.application?.[0].activity?.[0]['intent-filter']
if (intentFilter) {
intentFilter.push(...intents)
}
return config
})
}
module.exports = {withIntentFilters}

View File

@ -0,0 +1,47 @@
const {withPlugins} = require('@expo/config-plugins')
const {withAppEntitlements} = require('./withAppEntitlements')
const {withXcodeTarget} = require('./withXcodeTarget')
const {withExtensionEntitlements} = require('./withExtensionEntitlements')
const {withExtensionInfoPlist} = require('./withExtensionInfoPlist')
const {withExtensionViewController} = require('./withExtensionViewController')
const {withIntentFilters} = require('./withIntentFilters')
const SHARE_EXTENSION_NAME = 'Share-with-Bluesky'
const SHARE_EXTENSION_CONTROLLER_NAME = 'ShareViewController'
const withShareExtensions = config => {
return withPlugins(config, [
// IOS
withAppEntitlements,
[
withExtensionEntitlements,
{
extensionName: SHARE_EXTENSION_NAME,
},
],
[
withExtensionInfoPlist,
{
extensionName: SHARE_EXTENSION_NAME,
},
],
[
withExtensionViewController,
{
extensionName: SHARE_EXTENSION_NAME,
controllerName: SHARE_EXTENSION_CONTROLLER_NAME,
},
],
[
withXcodeTarget,
{
extensionName: SHARE_EXTENSION_NAME,
controllerName: SHARE_EXTENSION_CONTROLLER_NAME,
},
],
// Android
withIntentFilters,
])
}
module.exports = withShareExtensions

View File

@ -0,0 +1,56 @@
const {withXcodeProject} = require('@expo/config-plugins')
const withXcodeTarget = (config, {extensionName, controllerName}) => {
// eslint-disable-next-line no-shadow
return withXcodeProject(config, config => {
const pbxProject = config.modResults
const target = pbxProject.addTarget(
extensionName,
'app_extension',
extensionName,
)
pbxProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', target.uuid)
pbxProject.addBuildPhase(
[],
'PBXResourcesBuildPhase',
'Resources',
target.uuid,
)
const pbxGroupKey = pbxProject.pbxCreateGroup(extensionName, extensionName)
pbxProject.addFile(`${extensionName}/Info.plist`, pbxGroupKey)
pbxProject.addSourceFile(
`${extensionName}/${controllerName}.swift`,
{target: target.uuid},
pbxGroupKey,
)
var configurations = pbxProject.pbxXCBuildConfigurationSection()
for (var key in configurations) {
if (typeof configurations[key].buildSettings !== 'undefined') {
var buildSettingsObj = configurations[key].buildSettings
if (
typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' &&
buildSettingsObj.PRODUCT_NAME === `"${extensionName}"`
) {
buildSettingsObj.CLANG_ENABLE_MODULES = 'YES'
buildSettingsObj.INFOPLIST_FILE = `"${extensionName}/Info.plist"`
buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${extensionName}/${extensionName}.entitlements"`
buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'
buildSettingsObj.CURRENT_PROJECT_VERSION = `"${config.ios?.buildNumber}"`
buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES'
buildSettingsObj.MARKETING_VERSION = `"${config.version}"`
buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.${extensionName}"`
buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES'
buildSettingsObj.SWIFT_VERSION = '5.0'
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1,2"`
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
}
}
}
return config
})
}
module.exports = {withXcodeTarget}

View File

@ -0,0 +1,5 @@
# Tool Scripts
## updateExtensions.sh
Updates the extensions in `/modules` with the current iOS/Android project changes.

View File

@ -0,0 +1,10 @@
#!/bin/bash
IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
MODULES_DIRECTORY="./modules"
if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
echo "$IOS_SHARE_EXTENSION_DIRECTORY not found inside of your iOS project."
exit 1
else
cp -R $IOS_SHARE_EXTENSION_DIRECTORY $MODULES_DIRECTORY
fi

View File

@ -45,6 +45,7 @@ import {Splash} from '#/Splash'
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
@ -53,6 +54,7 @@ function InnerApp() {
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
const theme = useColorModeTheme() const theme = useColorModeTheme()
const {_} = useLingui() const {_} = useLingui()
useIntentHandler()
// init // init
useEffect(() => { useEffect(() => {

View File

@ -32,11 +32,13 @@ import {
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
function InnerApp() { function InnerApp() {
const {isInitialLoad, currentAccount} = useSession() const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
const theme = useColorModeTheme() const theme = useColorModeTheme()
useIntentHandler()
// init // init
useEffect(() => { useEffect(() => {

View File

@ -460,7 +460,8 @@ const FlatNavigator = () => {
*/ */
const LINKING = { const LINKING = {
prefixes: ['bsky://', 'https://bsky.app'], // TODO figure out what we are going to use
prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
getPathFromState(state: State) { getPathFromState(state: State) {
// find the current node in the navigation tree // find the current node in the navigation tree
@ -478,6 +479,11 @@ const LINKING = {
}, },
getStateFromPath(path: string) { getStateFromPath(path: string) {
// Any time we receive a url that starts with `intent/` we want to ignore it here. It will be handled in the
// intent handler hook. We should check for the trailing slash, because if there isn't one then it isn't a valid
// intent
if (path.includes('intent/')) return
const [name, params] = router.matchPath(path) const [name, params] = router.matchPath(path)
if (isNative) { if (isNative) {
if (name === 'Search') { if (name === 'Search') {

View File

@ -7,7 +7,6 @@ import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
import {InlineLink} from '#/components/Link' import {InlineLink} from '#/components/Link'
import {Text, TextProps} from '#/components/Typography' import {Text, TextProps} from '#/components/Typography'
import {toShortUrl} from 'lib/strings/url-helpers' import {toShortUrl} from 'lib/strings/url-helpers'
import {getAgent} from '#/state/session'
import {TagMenu, useTagMenuControl} from '#/components/TagMenu' import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
@ -20,7 +19,6 @@ export function RichText({
style, style,
numberOfLines, numberOfLines,
disableLinks, disableLinks,
resolveFacets = false,
selectable, selectable,
enableTags = false, enableTags = false,
authorHandle, authorHandle,
@ -30,31 +28,16 @@ export function RichText({
testID?: string testID?: string
numberOfLines?: number numberOfLines?: number
disableLinks?: boolean disableLinks?: boolean
resolveFacets?: boolean
enableTags?: boolean enableTags?: boolean
authorHandle?: string authorHandle?: string
}) { }) {
const detected = React.useRef(false) const richText = React.useMemo(
const [richText, setRichText] = React.useState<RichTextAPI>(() => () =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
[value],
) )
const styles = [a.leading_snug, flatten(style)] const styles = [a.leading_snug, flatten(style)]
React.useEffect(() => {
if (!resolveFacets) return
async function detectFacets() {
const rt = new RichTextAPI({text: richText.text})
await rt.detectFacets(getAgent())
setRichText(rt)
}
if (!detected.current) {
detected.current = true
detectFacets()
}
}, [richText, setRichText, resolveFacets])
const {text, facets} = richText const {text, facets} = richText
if (!facets?.length) { if (!facets?.length) {

View File

@ -12,6 +12,8 @@ import {
useUpsertMutedWordsMutation, useUpsertMutedWordsMutation,
useRemoveMutedWordMutation, useRemoveMutedWordMutation,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {enforceLen} from '#/lib/strings/helpers'
import {web} from '#/alf'
export function useTagMenuControl() {} export function useTagMenuControl() {}
@ -40,11 +42,12 @@ export function TagMenu({
)) && )) &&
!(optimisticRemove?.value === sanitizedTag), !(optimisticRemove?.value === sanitizedTag),
) )
const truncatedTag = enforceLen(tag, 15, true, 'middle')
const dropdownItems = React.useMemo(() => { const dropdownItems = React.useMemo(() => {
return [ return [
{ {
label: _(msg`See ${tag} posts`), label: _(msg`See ${truncatedTag} posts`),
onPress() { onPress() {
navigation.navigate('Search', { navigation.navigate('Search', {
q: tag, q: tag,
@ -61,7 +64,7 @@ export function TagMenu({
}, },
authorHandle && authorHandle &&
!isInvalidHandle(authorHandle) && { !isInvalidHandle(authorHandle) && {
label: _(msg`See ${tag} posts by this user`), label: _(msg`See ${truncatedTag} posts by user`),
onPress() { onPress() {
navigation.navigate({ navigation.navigate({
name: 'Search', name: 'Search',
@ -83,7 +86,9 @@ export function TagMenu({
label: 'separator', label: 'separator',
}, },
preferences && { preferences && {
label: isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`), label: isMuted
? _(msg`Unmute ${truncatedTag}`)
: _(msg`Mute ${truncatedTag}`),
onPress() { onPress() {
if (isMuted) { if (isMuted) {
removeMutedWord({value: sanitizedTag, targets: ['tag']}) removeMutedWord({value: sanitizedTag, targets: ['tag']})
@ -108,6 +113,7 @@ export function TagMenu({
navigation, navigation,
preferences, preferences,
tag, tag,
truncatedTag,
sanitizedTag, sanitizedTag,
upsertMutedWord, upsertMutedWord,
removeMutedWord, removeMutedWord,
@ -119,7 +125,10 @@ export function TagMenu({
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)} accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
accessibilityHint="" accessibilityHint=""
// @ts-ignore // @ts-ignore
items={dropdownItems}> items={dropdownItems}
triggerStyle={web({
textAlign: 'left',
})}>
{children} {children}
</NativeDropdown> </NativeDropdown>
</EventStopper> </EventStopper>

View File

@ -10,7 +10,7 @@ import {
useRemoveMutedWordMutation, useRemoveMutedWordMutation,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {atoms as a, useTheme, useBreakpoints, ViewStyleProp} from '#/alf' import {atoms as a, useTheme, useBreakpoints, ViewStyleProp, web} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
@ -260,9 +260,21 @@ function MutedWordRow({
a.align_center, a.align_center,
a.justify_between, a.justify_between,
a.rounded_md, a.rounded_md,
a.gap_md,
style, style,
]}> ]}>
<Text style={[a.font_bold, t.atoms.text_contrast_high]}> <Text
style={[
a.flex_1,
a.leading_snug,
a.w_full,
a.font_bold,
t.atoms.text_contrast_high,
web({
overflowWrap: 'break-word',
wordBreak: 'break-word',
}),
]}>
{word.value} {word.value}
</Text> </Text>

View File

@ -75,3 +75,9 @@ export const HITSLOP_20 = createHitslop(20)
export const HITSLOP_30 = createHitslop(30) export const HITSLOP_30 = createHitslop(30)
export const BACK_HITSLOP = HITSLOP_30 export const BACK_HITSLOP = HITSLOP_30
export const MAX_POST_LINES = 25 export const MAX_POST_LINES = 25
export const BSKY_FEED_OWNER_DIDS = [
'did:plc:z72i7hdynmk6r22z27h6tvur',
'did:plc:vpkhqolt662uhesyj6nxm7ys',
'did:plc:q6gjnaw2blty4crticxkmujt',
]

View File

@ -0,0 +1,87 @@
import React from 'react'
import * as Linking from 'expo-linking'
import {isNative} from 'platform/detection'
import {useComposerControls} from 'state/shell'
import {useSession} from 'state/session'
type IntentType = 'compose'
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
export function useIntentHandler() {
const incomingUrl = Linking.useURL()
const composeIntent = useComposeIntent()
React.useEffect(() => {
const handleIncomingURL = (url: string) => {
const urlp = new URL(url)
const [_, intentTypeNative, intentTypeWeb] = urlp.pathname.split('/')
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
// intent check. On web, we have to check the first part of the path since we have an actual hostname
const intentType = isNative ? intentTypeNative : intentTypeWeb
const isIntent = isNative
? urlp.hostname === 'intent'
: intentTypeNative === 'intent'
const params = urlp.searchParams
if (!isIntent) return
switch (intentType as IntentType) {
case 'compose': {
composeIntent({
text: params.get('text'),
imageUrisStr: params.get('imageUris'),
})
}
}
}
if (incomingUrl) handleIncomingURL(incomingUrl)
}, [incomingUrl, composeIntent])
}
function useComposeIntent() {
const {openComposer} = useComposerControls()
const {hasSession} = useSession()
return React.useCallback(
({
text,
imageUrisStr,
}: {
text: string | null
imageUrisStr: string | null // unused for right now, will be used later with intents
}) => {
if (!hasSession) return
const imageUris = imageUrisStr
?.split(',')
.filter(part => {
// For some security, we're going to filter out any image uri that is external. We don't want someone to
// be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
// and we load that image
if (part.includes('https://') || part.includes('http://')) {
return false
}
// We also should just filter out cases that don't have all the info we need
if (!VALID_IMAGE_REGEX.test(part)) {
return false
}
return true
})
.map(part => {
const [uri, width, height] = part.split('|')
return {uri, width: Number(width), height: Number(height)}
})
setTimeout(() => {
openComposer({
text: text ?? undefined,
imageUris: isNative ? imageUris : undefined,
})
}, 500)
},
[openComposer, hasSession],
)
}

View File

@ -8,10 +8,27 @@ export function pluralize(n: number, base: string, plural?: string): string {
return base + 's' return base + 's'
} }
export function enforceLen(str: string, len: number, ellipsis = false): string { export function enforceLen(
str: string,
len: number,
ellipsis = false,
mode: 'end' | 'middle' = 'end',
): string {
str = str || '' str = str || ''
if (str.length > len) { if (str.length > len) {
return str.slice(0, len) + (ellipsis ? '...' : '') if (ellipsis) {
if (mode === 'end') {
return str.slice(0, len) + '…'
} else if (mode === 'middle') {
const half = Math.floor(len / 2)
return str.slice(0, half) + '…' + str.slice(-half)
} else {
// fallback
return str.slice(0, len)
}
} else {
return str.slice(0, len)
}
} }
return str return str
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,11 +4,21 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {openPicker} from 'lib/media/picker' import {openPicker} from 'lib/media/picker'
import {getImageDim} from 'lib/media/manip' import {getImageDim} from 'lib/media/manip'
interface InitialImageUri {
uri: string
width: number
height: number
}
export class GalleryModel { export class GalleryModel {
images: ImageModel[] = [] images: ImageModel[] = []
constructor() { constructor(uris?: {uri: string; width: number; height: number}[]) {
makeAutoObservable(this) makeAutoObservable(this)
if (uris) {
this.addFromUris(uris)
}
} }
get isEmpty() { get isEmpty() {
@ -23,7 +33,7 @@ export class GalleryModel {
return this.images.some(image => image.altText.trim() === '') return this.images.some(image => image.altText.trim() === '')
} }
async add(image_: Omit<RNImage, 'size'>) { *add(image_: Omit<RNImage, 'size'>) {
if (this.size >= 4) { if (this.size >= 4) {
return return
} }
@ -86,4 +96,15 @@ export class GalleryModel {
}), }),
) )
} }
async addFromUris(uris: InitialImageUri[]) {
for (const uriObj of uris) {
this.add({
mime: 'image/jpeg',
height: uriObj.height,
width: uriObj.width,
path: uriObj.uri,
})
}
}
} }

View File

@ -1,6 +1,11 @@
import React, {useCallback, useEffect, useRef} from 'react' import React, {useCallback, useEffect, useRef} from 'react'
import {AppState} from 'react-native' import {AppState} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api' import {
AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
PostModeration,
} from '@atproto/api'
import { import {
useInfiniteQuery, useInfiniteQuery,
InfiniteData, InfiniteData,
@ -29,6 +34,7 @@ import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {embedViewRecordToPostView, getEmbeddedPost} from './util' import {embedViewRecordToPostView, getEmbeddedPost} from './util'
import {useModerationOpts} from './preferences' import {useModerationOpts} from './preferences'
import {queryClient} from 'lib/react-query' import {queryClient} from 'lib/react-query'
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
type ActorDid = string type ActorDid = string
type AuthorFilter = type AuthorFilter =
@ -137,24 +143,41 @@ export function usePostFeedQuery(
cursor: undefined, cursor: undefined,
} }
const res = await api.fetch({cursor, limit: PAGE_SIZE}) try {
precacheFeedPostProfiles(queryClient, res.feed) const res = await api.fetch({cursor, limit: PAGE_SIZE})
precacheFeedPostProfiles(queryClient, res.feed)
/* /*
* If this is a public view, we need to check if posts fail moderation. * If this is a public view, we need to check if posts fail moderation.
* If all fail, we throw an error. If only some fail, we continue and let * If all fail, we throw an error. If only some fail, we continue and let
* moderations happen later, which results in some posts being shown and * moderations happen later, which results in some posts being shown and
* some not. * some not.
*/ */
if (!getAgent().session) { if (!getAgent().session) {
assertSomePostsPassModeration(res.feed) assertSomePostsPassModeration(res.feed)
} }
return { return {
api, api,
cursor: res.cursor, cursor: res.cursor,
feed: res.feed, feed: res.feed,
fetchedAt: Date.now(), fetchedAt: Date.now(),
}
} catch (e) {
const feedDescParts = feedDesc.split('|')
const feedOwnerDid = new AtUri(feedDescParts[1]).hostname
if (
feedDescParts[0] === 'feedgen' &&
BSKY_FEED_OWNER_DIDS.includes(feedOwnerDid)
) {
logger.error(`Bluesky feed may be offline: ${feedOwnerDid}`, {
feedDesc,
jsError: e,
})
}
throw e
} }
}, },
initialPageParam: undefined, initialPageParam: undefined,
@ -253,7 +276,7 @@ export function usePostFeedQuery(
.success .success
) { ) {
return { return {
_reactKey: `${slice._reactKey}-${i}`, _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
uri: item.post.uri, uri: item.post.uri,
post: item.post, post: item.post,
record: item.post.record, record: item.post.record,

View File

@ -38,6 +38,8 @@ export interface ComposerOpts {
quote?: ComposerOptsQuote quote?: ComposerOptsQuote
mention?: string // handle of user to mention mention?: string // handle of user to mention
openPicker?: (pos: DOMRect | undefined) => void openPicker?: (pos: DOMRect | undefined) => void
text?: string
imageUris?: {uri: string; width: number; height: number}[]
} }
type StateContext = ComposerOpts | undefined type StateContext = ComposerOpts | undefined

View File

@ -71,6 +71,8 @@ export const ComposePost = observer(function ComposePost({
quote: initQuote, quote: initQuote,
mention: initMention, mention: initMention,
openPicker, openPicker,
text: initText,
imageUris: initImageUris,
}: Props) { }: Props) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@ -91,7 +93,9 @@ export const ComposePost = observer(function ComposePost({
const [error, setError] = useState('') const [error, setError] = useState('')
const [richtext, setRichText] = useState( const [richtext, setRichText] = useState(
new RichText({ new RichText({
text: initMention text: initText
? initText
: initMention
? insertMentionAt( ? insertMentionAt(
`@${initMention}`, `@${initMention}`,
initMention.length + 1, initMention.length + 1,
@ -110,7 +114,10 @@ export const ComposePost = observer(function ComposePost({
const [labels, setLabels] = useState<string[]>([]) const [labels, setLabels] = useState<string[]>([])
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([]) const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
const gallery = useMemo(() => new GalleryModel(), []) const gallery = useMemo(
() => new GalleryModel(initImageUris),
[initImageUris],
)
const onClose = useCallback(() => { const onClose = useCallback(() => {
closeComposer() closeComposer()
}, [closeComposer]) }, [closeComposer])

View File

@ -1,30 +1,24 @@
import React from 'react' import React from 'react'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {MainScrollProvider} from '../util/MainScrollProvider' import {MainScrollProvider} from '../util/MainScrollProvider'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {ComposeIcon2} from 'lib/icons' import {ComposeIcon2} from 'lib/icons'
import {colors, s} from 'lib/styles' import {s} from 'lib/styles'
import {View, useWindowDimensions} from 'react-native' import {View, useWindowDimensions} from 'react-native'
import {ListMethods} from '../util/List' import {ListMethods} from '../util/List'
import {Feed} from '../posts/Feed' import {Feed} from '../posts/Feed'
import {TextLink} from '../util/Link'
import {FAB} from '../util/fab/FAB' import {FAB} from '../util/fab/FAB'
import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn' import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {listenSoftReset, emitSoftReset} from '#/state/events' import {listenSoftReset} from '#/state/events'
import {truncateAndInvalidate} from '#/state/queries/util' import {truncateAndInvalidate} from '#/state/queries/util'
import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers' import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
@ -47,10 +41,8 @@ export function FeedPage({
renderEndOfFeed?: () => JSX.Element renderEndOfFeed?: () => JSX.Element
}) { }) {
const {hasSession} = useSession() const {hasSession} = useSession()
const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation() const navigation = useNavigation()
const {isDesktop} = useWebMediaQueries()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {openComposer} = useComposerControls() const {openComposer} = useComposerControls()
const [isScrolledDown, setIsScrolledDown] = React.useState(false) const [isScrolledDown, setIsScrolledDown] = React.useState(false)
@ -99,63 +91,6 @@ export function FeedPage({
setHasNew(false) setHasNew(false)
}, [scrollToTop, feed, queryClient, setHasNew]) }, [scrollToTop, feed, queryClient, setHasNew])
const ListHeaderComponent = React.useCallback(() => {
if (isDesktop) {
return (
<View
style={[
pal.view,
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
paddingVertical: 12,
},
]}>
<TextLink
type="title-lg"
href="/"
style={[pal.text, {fontWeight: 'bold'}]}
text={
<>
Bluesky{' '}
{hasNew && (
<View
style={{
top: -8,
backgroundColor: colors.blue3,
width: 8,
height: 8,
borderRadius: 4,
}}
/>
)}
</>
}
onPress={emitSoftReset}
/>
{hasSession && (
<TextLink
type="title-lg"
href="/settings/following-feed"
style={{fontWeight: 'bold'}}
accessibilityLabel={_(msg`Feed Preferences`)}
accessibilityHint=""
text={
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
}
/>
)}
</View>
)
}
return <></>
}, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, hasSession])
return ( return (
<View testID={testID} style={s.h100pct}> <View testID={testID} style={s.h100pct}>
<MainScrollProvider> <MainScrollProvider>
@ -171,7 +106,6 @@ export function FeedPage({
onHasNew={setHasNew} onHasNew={setHasNew}
renderEmptyState={renderEmptyState} renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed} renderEndOfFeed={renderEndOfFeed}
ListHeaderComponent={ListHeaderComponent}
headerOffset={headerOffset} headerOffset={headerOffset}
/> />
</MainScrollProvider> </MainScrollProvider>

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {RenderTabBarFnProps} from 'view/com/pager/Pager'
import {HomeHeaderLayout} from './HomeHeaderLayout' import {HomeHeaderLayout} from './HomeHeaderLayout'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePinnedFeedsInfos} from '#/state/queries/feed' import {usePinnedFeedsInfos} from '#/state/queries/feed'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
@ -11,16 +10,6 @@ import {usePalette} from '#/lib/hooks/usePalette'
export function HomeHeader( export function HomeHeader(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const {isDesktop} = useWebMediaQueries()
if (isDesktop) {
return null
}
return <HomeHeaderInner {...props} />
}
export function HomeHeaderInner(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) { ) {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {feeds, hasPinnedCustom} = usePinnedFeedsInfos() const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()

View File

@ -1,11 +1,20 @@
import React from 'react' import React from 'react'
import {StyleSheet} from 'react-native' import {StyleSheet, View} from 'react-native'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile' import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
import {Logo} from '#/view/icons/Logo'
import {Link, TextLink} from '../util/Link'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {CogIcon} from '#/lib/icons'
export function HomeHeaderLayout({children}: {children: React.ReactNode}) { export function HomeHeaderLayout({children}: {children: React.ReactNode}) {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
@ -20,6 +29,7 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
const pal = usePalette('default') const pal = usePalette('default')
const {headerMinimalShellTransform} = useMinimalShellMode() const {headerMinimalShellTransform} = useMinimalShellMode()
const {headerHeight} = useShellLayout() const {headerHeight} = useShellLayout()
const {_} = useLingui()
return ( return (
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
@ -28,12 +38,44 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
onLayout={e => { onLayout={e => {
headerHeight.value = e.nativeEvent.layout.height headerHeight.value = e.nativeEvent.layout.height
}}> }}>
<View style={[pal.view, styles.topBar]}>
<TextLink
type="title-lg"
href="/settings/following-feed"
accessibilityLabel={_(msg`Following Feed Preferences`)}
accessibilityHint=""
text={
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
}
/>
<Logo width={28} />
<Link
href="/settings/saved-feeds"
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={_(msg`Edit Saved Feeds`)}
accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
</Link>
</View>
{children} {children}
</Animated.View> </Animated.View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
topBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 8,
marginTop: 8,
width: '100%',
},
tabBar: { tabBar: {
// @ts-ignore Web only // @ts-ignore Web only
position: 'sticky', position: 'sticky',
@ -42,7 +84,7 @@ const styles = StyleSheet.create({
left: 'calc(50% - 300px)', left: 'calc(50% - 300px)',
width: 600, width: 600,
top: 0, top: 0,
flexDirection: 'row', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
borderLeftWidth: 1, borderLeftWidth: 1,
borderRightWidth: 1, borderRightWidth: 1,

View File

@ -103,7 +103,6 @@ const styles = StyleSheet.create({
right: 0, right: 0,
top: 0, top: 0,
flexDirection: 'column', flexDirection: 'column',
borderBottomWidth: 1,
}, },
topBar: { topBar: {
flexDirection: 'row', flexDirection: 'row',

View File

@ -5,6 +5,7 @@ import {PressableWithHover} from '../util/PressableWithHover'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {DraggableScrollView} from './DraggableScrollView' import {DraggableScrollView} from './DraggableScrollView'
import {isNative} from '#/platform/detection'
export interface TabBarProps { export interface TabBarProps {
testID?: string testID?: string
@ -15,6 +16,10 @@ export interface TabBarProps {
onPressSelected?: (index: number) => void onPressSelected?: (index: number) => void
} }
// How much of the previous/next item we're showing
// to give the user a hint there's more to scroll.
const OFFSCREEN_ITEM_WIDTH = 20
export function TabBar({ export function TabBar({
testID, testID,
selectedPage, selectedPage,
@ -25,6 +30,7 @@ export function TabBar({
}: TabBarProps) { }: TabBarProps) {
const pal = usePalette('default') const pal = usePalette('default')
const scrollElRef = useRef<ScrollView>(null) const scrollElRef = useRef<ScrollView>(null)
const itemRefs = useRef<Array<Element>>([])
const [itemXs, setItemXs] = useState<number[]>([]) const [itemXs, setItemXs] = useState<number[]>([])
const indicatorStyle = useMemo( const indicatorStyle = useMemo(
() => ({borderBottomColor: indicatorColor || pal.colors.link}), () => ({borderBottomColor: indicatorColor || pal.colors.link}),
@ -33,12 +39,58 @@ export function TabBar({
const {isDesktop, isTablet} = useWebMediaQueries() const {isDesktop, isTablet} = useWebMediaQueries()
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
// scrolls to the selected item when the page changes
useEffect(() => { useEffect(() => {
scrollElRef.current?.scrollTo({ if (isNative) {
x: // On native, the primary interaction is swiping.
(itemXs[selectedPage] || 0) - styles.contentContainer.paddingHorizontal, // We adjust the scroll little by little on every tab change.
}) // Scroll into view but keep the end of the previous item visible.
let x = itemXs[selectedPage] || 0
x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH)
scrollElRef.current?.scrollTo({x})
} else {
// On the web, the primary interaction is tapping.
// Scrolling under tap feels disorienting so only adjust the scroll offset
// when tapping on an item out of view--and we adjust by almost an entire page.
const parent = scrollElRef?.current?.getScrollableNode?.()
if (!parent) {
return
}
const parentRect = parent.getBoundingClientRect()
if (!parentRect) {
return
}
const {
left: parentLeft,
right: parentRight,
width: parentWidth,
} = parentRect
const child = itemRefs.current[selectedPage]
if (!child) {
return
}
const childRect = child.getBoundingClientRect?.()
if (!childRect) {
return
}
const {left: childLeft, right: childRight, width: childWidth} = childRect
let dx = 0
if (childRight >= parentRight) {
dx += childRight - parentRight
dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
} else if (childLeft <= parentLeft) {
dx -= parentLeft - childLeft
dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
}
let x = parent.scrollLeft + dx
x = Math.max(0, x)
x = Math.min(x, parent.scrollWidth - parentWidth)
if (dx !== 0) {
parent.scroll({
left: x,
behavior: 'smooth',
})
}
}
}, [scrollElRef, itemXs, selectedPage, styles]) }, [scrollElRef, itemXs, selectedPage, styles])
const onPressItem = useCallback( const onPressItem = useCallback(
@ -78,6 +130,7 @@ export function TabBar({
<PressableWithHover <PressableWithHover
testID={`${testID}-selector-${i}`} testID={`${testID}-selector-${i}`}
key={`${item}-${i}`} key={`${item}-${i}`}
ref={node => (itemRefs.current[i] = node)}
onLayout={e => onItemLayout(e, i)} onLayout={e => onItemLayout(e, i)}
style={styles.item} style={styles.item}
hoverStyle={pal.viewLight} hoverStyle={pal.viewLight}
@ -94,6 +147,7 @@ export function TabBar({
) )
})} })}
</DraggableScrollView> </DraggableScrollView>
<View style={[pal.border, styles.outerBottomBorder]} />
</View> </View>
) )
} }
@ -117,6 +171,13 @@ const desktopStyles = StyleSheet.create({
borderBottomWidth: 3, borderBottomWidth: 3,
borderBottomColor: 'transparent', borderBottomColor: 'transparent',
}, },
outerBottomBorder: {
position: 'absolute',
left: 0,
right: 0,
bottom: -1,
borderBottomWidth: 1,
},
}) })
const mobileStyles = StyleSheet.create({ const mobileStyles = StyleSheet.create({
@ -137,4 +198,11 @@ const mobileStyles = StyleSheet.create({
borderBottomWidth: 3, borderBottomWidth: 3,
borderBottomColor: 'transparent', borderBottomColor: 'transparent',
}, },
outerBottomBorder: {
position: 'absolute',
left: 0,
right: 0,
bottom: -1,
borderBottomWidth: 1,
},
}) })

View File

@ -94,6 +94,8 @@ export function PostThreadItem({
if (richText && moderation) { if (richText && moderation) {
return ( return (
<PostThreadItemLoaded <PostThreadItemLoaded
// Safeguard from clobbering per-post state below:
key={postShadowed.uri}
post={postShadowed} post={postShadowed}
prevPost={prevPost} prevPost={prevPost}
nextPost={nextPost} nextPost={nextPost}

View File

@ -70,6 +70,8 @@ export function FeedItem({
if (richText && moderation) { if (richText && moderation) {
return ( return (
<FeedItemInner <FeedItemInner
// Safeguard from clobbering per-post state below:
key={postShadowed.uri}
post={postShadowed} post={postShadowed}
record={record} record={record}
reason={reason} reason={reason}

View File

@ -20,12 +20,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
const setMode = useSetMinimalShellMode() const setMode = useSetMinimalShellMode()
const startDragOffset = useSharedValue<number | null>(null) const startDragOffset = useSharedValue<number | null>(null)
const startMode = useSharedValue<number | null>(null) const startMode = useSharedValue<number | null>(null)
const didJustRestoreScroll = useSharedValue<boolean>(false)
useEffect(() => { useEffect(() => {
if (isWeb) { if (isWeb) {
return listenToForcedWindowScroll(() => { return listenToForcedWindowScroll(() => {
startDragOffset.value = null startDragOffset.value = null
startMode.value = null startMode.value = null
didJustRestoreScroll.value = true
}) })
} }
}) })
@ -86,6 +88,11 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
mode.value = newValue mode.value = newValue
} }
} else { } else {
if (didJustRestoreScroll.value) {
didJustRestoreScroll.value = false
// Don't hide/show navbar based on scroll restoratoin.
return
}
// On the web, we don't try to follow the drag because we don't know when it ends. // On the web, we don't try to follow the drag because we don't know when it ends.
// Instead, show/hide immediately based on whether we're scrolling up or down. // Instead, show/hide immediately based on whether we're scrolling up or down.
const dy = e.contentOffset.y - (startDragOffset.value ?? 0) const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
@ -98,7 +105,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
} }
} }
}, },
[headerHeight, mode, setMode, startDragOffset, startMode], [
headerHeight,
mode,
setMode,
startDragOffset,
startMode,
didJustRestoreScroll,
],
) )
return ( return (

View File

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as DropdownMenu from 'zeego/dropdown-menu' import * as DropdownMenu from 'zeego/dropdown-menu'
import {Pressable, StyleSheet, Platform, View} from 'react-native' import {Pressable, StyleSheet, Platform, View, ViewStyle} from 'react-native'
import {IconProp} from '@fortawesome/fontawesome-svg-core' import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {MenuItemCommonProps} from 'zeego/lib/typescript/menu' import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
@ -151,6 +151,7 @@ type Props = {
testID?: string testID?: string
accessibilityLabel?: string accessibilityLabel?: string
accessibilityHint?: string accessibilityHint?: string
triggerStyle?: ViewStyle
} }
/* The `NativeDropdown` function uses native iOS and Android dropdown menus. /* The `NativeDropdown` function uses native iOS and Android dropdown menus.

View File

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {Pressable, StyleSheet, View, Text} from 'react-native' import {Pressable, StyleSheet, View, Text, ViewStyle} from 'react-native'
import {IconProp} from '@fortawesome/fontawesome-svg-core' import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {MenuItemCommonProps} from 'zeego/lib/typescript/menu' import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
@ -53,6 +53,7 @@ type Props = {
testID?: string testID?: string
accessibilityLabel?: string accessibilityLabel?: string
accessibilityHint?: string accessibilityHint?: string
triggerStyle?: ViewStyle
} }
export function NativeDropdown({ export function NativeDropdown({
@ -61,6 +62,7 @@ export function NativeDropdown({
testID, testID,
accessibilityLabel, accessibilityLabel,
accessibilityHint, accessibilityHint,
triggerStyle,
}: React.PropsWithChildren<Props>) { }: React.PropsWithChildren<Props>) {
const pal = usePalette('default') const pal = usePalette('default')
const theme = useTheme() const theme = useTheme()
@ -120,7 +122,8 @@ export function NativeDropdown({
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
onPress={() => setOpen(o => !o)} onPress={() => setOpen(o => !o)}
hitSlop={HITSLOP_10}> hitSlop={HITSLOP_10}
style={triggerStyle}>
{children} {children}
</Pressable> </Pressable>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>

View File

@ -22,12 +22,14 @@ export function Typography() {
<Text style={[a.text_2xs]}>atoms.text_2xs</Text> <Text style={[a.text_2xs]}>atoms.text_2xs</Text>
<RichText <RichText
resolveFacets // TODO: This only supports already resolved facets.
// Resolving them on read is bad anyway.
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`} value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
/> />
<RichText <RichText
selectable selectable
resolveFacets // TODO: This only supports already resolved facets.
// Resolving them on read is bad anyway.
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`} value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
style={[a.text_xl]} style={[a.text_xl]}
/> />

View File

@ -55,6 +55,8 @@ export const Composer = observer(function ComposerImpl({
onPost={state.onPost} onPost={state.onPost}
quote={state.quote} quote={state.quote}
mention={state.mention} mention={state.mention}
text={state.text}
imageUris={state.imageUris}
/> />
</Animated.View> </Animated.View>
) )

View File

@ -9,7 +9,7 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import { import {
EmojiPicker, EmojiPicker,
EmojiPickerState, EmojiPickerState,
} from 'view/com/composer/text-input/web/EmojiPicker.web.tsx' } from 'view/com/composer/text-input/web/EmojiPicker.web'
const BOTTOM_BAR_HEIGHT = 61 const BOTTOM_BAR_HEIGHT = 61
@ -69,6 +69,7 @@ export function Composer({}: {winHeight: number}) {
onPost={state.onPost} onPost={state.onPost}
mention={state.mention} mention={state.mention}
openPicker={onOpenPicker} openPicker={onOpenPicker}
text={state.text}
/> />
</Animated.View> </Animated.View>
<EmojiPicker state={pickerState} close={onClosePicker} /> <EmojiPicker state={pickerState} close={onClosePicker} />

View File

@ -11739,6 +11739,14 @@ expo-keep-awake@~12.8.1:
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-12.8.1.tgz#3c8df9d86c265741b5e7bdd36965aa0c6fc17df0" resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-12.8.1.tgz#3c8df9d86c265741b5e7bdd36965aa0c6fc17df0"
integrity sha512-P/VZFV02Rzgj13skMwH+ceGOGZSEdaUu5n7pCS3wThh2LppZjPJ7sBxUwyzeLa3DXEVUtwLZi+BiQ91wPwy9Gg== integrity sha512-P/VZFV02Rzgj13skMwH+ceGOGZSEdaUu5n7pCS3wThh2LppZjPJ7sBxUwyzeLa3DXEVUtwLZi+BiQ91wPwy9Gg==
expo-linking@^6.2.2:
version "6.2.2"
resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-6.2.2.tgz#b7e148068ae49fd9ad814428c16fdf7a236e8aca"
integrity sha512-FEe6lP4f7xFT/vjoHRG+tt6EPVtkEGaWNK1smpaUevmNdyCJKqW0PDB8o8sfG6y7fly8ULe8qg3HhKh5J7aqUQ==
dependencies:
expo-constants "~15.4.3"
invariant "^2.2.4"
expo-localization@~14.8.2: expo-localization@~14.8.2:
version "14.8.2" version "14.8.2"
resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-14.8.2.tgz#e0bbed2293265834d21a1c58d3a5f8d265bd04ae" resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-14.8.2.tgz#e0bbed2293265834d21a1c58d3a5f8d265bd04ae"