Merge branch 'bluesky-social:main' into patch-3
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# OSX
|
||||||
|
#
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Android/IntelliJ
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Bundle artifacts
|
||||||
|
*.jsbundle
|
||||||
@@ -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()}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<manifest>
|
||||||
|
</manifest>
|
||||||
+119
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["android"],
|
||||||
|
"android": {
|
||||||
|
"modules": ["xyz.blueskyweb.app.exporeceiveandroidintents.ExpoReceiveAndroidIntentsModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Tool Scripts
|
||||||
|
|
||||||
|
## updateExtensions.sh
|
||||||
|
|
||||||
|
Updates the extensions in `/modules` with the current iOS/Android project changes.
|
||||||
Executable
+10
@@ -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
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
+7
-1
@@ -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') {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]
|
||||||
|
|||||||
@@ -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],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+266
-101
File diff suppressed because it is too large
Load Diff
+266
-101
File diff suppressed because it is too large
Load Diff
+266
-101
File diff suppressed because it is too large
Load Diff
+266
-101
File diff suppressed because it is too large
Load Diff
+266
-101
File diff suppressed because it is too large
Load Diff
+266
-101
File diff suppressed because it is too large
Load Diff
+266
-101
File diff suppressed because it is too large
Load Diff
+266
-101
File diff suppressed because it is too large
Load Diff
+266
-101
File diff suppressed because it is too large
Load Diff
+266
-101
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+266
-101
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user