Merge branch 'bluesky-social:main' into patch-3
This commit is contained in:
@@ -49,10 +49,14 @@ jobs:
|
||||
- name: ⚙️ Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: yarn intl:build
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
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.
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: yarn intl:build
|
||||
|
||||
- name: ✏️ Write environment variables
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
|
||||
@@ -89,6 +89,10 @@ module.exports = function (config) {
|
||||
scheme: 'https',
|
||||
host: 'bsky.app',
|
||||
},
|
||||
{
|
||||
scheme: 'http',
|
||||
host: 'localhost:19006',
|
||||
},
|
||||
],
|
||||
category: ['BROWSABLE', 'DEFAULT'],
|
||||
},
|
||||
@@ -137,6 +141,7 @@ module.exports = function (config) {
|
||||
},
|
||||
],
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/shareExtension/withShareExtensions.js',
|
||||
].filter(Boolean),
|
||||
extra: {
|
||||
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:results": "NODE_ENV=test flashlight report .perf/results.json",
|
||||
"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:extract": "lingui extract",
|
||||
"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": {
|
||||
"@atproto/api": "^0.10.0",
|
||||
@@ -109,6 +110,7 @@
|
||||
"expo-image": "~1.10.3",
|
||||
"expo-image-manipulator": "^11.8.0",
|
||||
"expo-image-picker": "~14.7.1",
|
||||
"expo-linking": "^6.2.2",
|
||||
"expo-localization": "~14.8.2",
|
||||
"expo-media-library": "~15.9.1",
|
||||
"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 {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
@@ -53,6 +54,7 @@ function InnerApp() {
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
|
||||
@@ -32,11 +32,13 @@ import {
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
|
||||
+7
-1
@@ -460,7 +460,8 @@ const FlatNavigator = () => {
|
||||
*/
|
||||
|
||||
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) {
|
||||
// find the current node in the navigation tree
|
||||
@@ -478,6 +479,11 @@ const LINKING = {
|
||||
},
|
||||
|
||||
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)
|
||||
if (isNative) {
|
||||
if (name === 'Search') {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
@@ -20,7 +19,6 @@ export function RichText({
|
||||
style,
|
||||
numberOfLines,
|
||||
disableLinks,
|
||||
resolveFacets = false,
|
||||
selectable,
|
||||
enableTags = false,
|
||||
authorHandle,
|
||||
@@ -30,31 +28,16 @@ export function RichText({
|
||||
testID?: string
|
||||
numberOfLines?: number
|
||||
disableLinks?: boolean
|
||||
resolveFacets?: boolean
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
}) {
|
||||
const detected = React.useRef(false)
|
||||
const [richText, setRichText] = React.useState<RichTextAPI>(() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
[value],
|
||||
)
|
||||
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
|
||||
|
||||
if (!facets?.length) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
useUpsertMutedWordsMutation,
|
||||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {web} from '#/alf'
|
||||
|
||||
export function useTagMenuControl() {}
|
||||
|
||||
@@ -40,11 +42,12 @@ export function TagMenu({
|
||||
)) &&
|
||||
!(optimisticRemove?.value === sanitizedTag),
|
||||
)
|
||||
const truncatedTag = enforceLen(tag, 15, true, 'middle')
|
||||
|
||||
const dropdownItems = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: _(msg`See ${tag} posts`),
|
||||
label: _(msg`See ${truncatedTag} posts`),
|
||||
onPress() {
|
||||
navigation.navigate('Search', {
|
||||
q: tag,
|
||||
@@ -61,7 +64,7 @@ export function TagMenu({
|
||||
},
|
||||
authorHandle &&
|
||||
!isInvalidHandle(authorHandle) && {
|
||||
label: _(msg`See ${tag} posts by this user`),
|
||||
label: _(msg`See ${truncatedTag} posts by user`),
|
||||
onPress() {
|
||||
navigation.navigate({
|
||||
name: 'Search',
|
||||
@@ -83,7 +86,9 @@ export function TagMenu({
|
||||
label: 'separator',
|
||||
},
|
||||
preferences && {
|
||||
label: isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`),
|
||||
label: isMuted
|
||||
? _(msg`Unmute ${truncatedTag}`)
|
||||
: _(msg`Mute ${truncatedTag}`),
|
||||
onPress() {
|
||||
if (isMuted) {
|
||||
removeMutedWord({value: sanitizedTag, targets: ['tag']})
|
||||
@@ -108,6 +113,7 @@ export function TagMenu({
|
||||
navigation,
|
||||
preferences,
|
||||
tag,
|
||||
truncatedTag,
|
||||
sanitizedTag,
|
||||
upsertMutedWord,
|
||||
removeMutedWord,
|
||||
@@ -119,7 +125,10 @@ export function TagMenu({
|
||||
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
|
||||
accessibilityHint=""
|
||||
// @ts-ignore
|
||||
items={dropdownItems}>
|
||||
items={dropdownItems}
|
||||
triggerStyle={web({
|
||||
textAlign: 'left',
|
||||
})}>
|
||||
{children}
|
||||
</NativeDropdown>
|
||||
</EventStopper>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
@@ -260,9 +260,21 @@ function MutedWordRow({
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.rounded_md,
|
||||
a.gap_md,
|
||||
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}
|
||||
</Text>
|
||||
|
||||
|
||||
@@ -75,3 +75,9 @@ export const HITSLOP_20 = createHitslop(20)
|
||||
export const HITSLOP_30 = createHitslop(30)
|
||||
export const BACK_HITSLOP = HITSLOP_30
|
||||
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'
|
||||
}
|
||||
|
||||
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 || ''
|
||||
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
|
||||
}
|
||||
|
||||
+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 {getImageDim} from 'lib/media/manip'
|
||||
|
||||
interface InitialImageUri {
|
||||
uri: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export class GalleryModel {
|
||||
images: ImageModel[] = []
|
||||
|
||||
constructor() {
|
||||
constructor(uris?: {uri: string; width: number; height: number}[]) {
|
||||
makeAutoObservable(this)
|
||||
|
||||
if (uris) {
|
||||
this.addFromUris(uris)
|
||||
}
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
@@ -23,7 +33,7 @@ export class GalleryModel {
|
||||
return this.images.some(image => image.altText.trim() === '')
|
||||
}
|
||||
|
||||
async add(image_: Omit<RNImage, 'size'>) {
|
||||
*add(image_: Omit<RNImage, 'size'>) {
|
||||
if (this.size >= 4) {
|
||||
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 {AppState} from 'react-native'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AtUri,
|
||||
PostModeration,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
@@ -29,6 +34,7 @@ import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
||||
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
|
||||
import {useModerationOpts} from './preferences'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
|
||||
|
||||
type ActorDid = string
|
||||
type AuthorFilter =
|
||||
@@ -137,24 +143,41 @@ export function usePostFeedQuery(
|
||||
cursor: undefined,
|
||||
}
|
||||
|
||||
const res = await api.fetch({cursor, limit: PAGE_SIZE})
|
||||
precacheFeedPostProfiles(queryClient, res.feed)
|
||||
try {
|
||||
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 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
|
||||
* some not.
|
||||
*/
|
||||
if (!getAgent().session) {
|
||||
assertSomePostsPassModeration(res.feed)
|
||||
}
|
||||
/*
|
||||
* 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
|
||||
* moderations happen later, which results in some posts being shown and
|
||||
* some not.
|
||||
*/
|
||||
if (!getAgent().session) {
|
||||
assertSomePostsPassModeration(res.feed)
|
||||
}
|
||||
|
||||
return {
|
||||
api,
|
||||
cursor: res.cursor,
|
||||
feed: res.feed,
|
||||
fetchedAt: Date.now(),
|
||||
return {
|
||||
api,
|
||||
cursor: res.cursor,
|
||||
feed: res.feed,
|
||||
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,
|
||||
@@ -253,7 +276,7 @@ export function usePostFeedQuery(
|
||||
.success
|
||||
) {
|
||||
return {
|
||||
_reactKey: `${slice._reactKey}-${i}`,
|
||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||
uri: item.post.uri,
|
||||
post: item.post,
|
||||
record: item.post.record,
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface ComposerOpts {
|
||||
quote?: ComposerOptsQuote
|
||||
mention?: string // handle of user to mention
|
||||
openPicker?: (pos: DOMRect | undefined) => void
|
||||
text?: string
|
||||
imageUris?: {uri: string; width: number; height: number}[]
|
||||
}
|
||||
|
||||
type StateContext = ComposerOpts | undefined
|
||||
|
||||
@@ -71,6 +71,8 @@ export const ComposePost = observer(function ComposePost({
|
||||
quote: initQuote,
|
||||
mention: initMention,
|
||||
openPicker,
|
||||
text: initText,
|
||||
imageUris: initImageUris,
|
||||
}: Props) {
|
||||
const {currentAccount} = useSession()
|
||||
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
||||
@@ -91,7 +93,9 @@ export const ComposePost = observer(function ComposePost({
|
||||
const [error, setError] = useState('')
|
||||
const [richtext, setRichText] = useState(
|
||||
new RichText({
|
||||
text: initMention
|
||||
text: initText
|
||||
? initText
|
||||
: initMention
|
||||
? insertMentionAt(
|
||||
`@${initMention}`,
|
||||
initMention.length + 1,
|
||||
@@ -110,7 +114,10 @@ export const ComposePost = observer(function ComposePost({
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
|
||||
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
|
||||
const gallery = useMemo(() => new GalleryModel(), [])
|
||||
const gallery = useMemo(
|
||||
() => new GalleryModel(initImageUris),
|
||||
[initImageUris],
|
||||
)
|
||||
const onClose = useCallback(() => {
|
||||
closeComposer()
|
||||
}, [closeComposer])
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {MainScrollProvider} from '../util/MainScrollProvider'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
||||
import {ComposeIcon2} from 'lib/icons'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {s} from 'lib/styles'
|
||||
import {View, useWindowDimensions} from 'react-native'
|
||||
import {ListMethods} from '../util/List'
|
||||
import {Feed} from '../posts/Feed'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {FAB} from '../util/fab/FAB'
|
||||
import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {listenSoftReset, emitSoftReset} from '#/state/events'
|
||||
import {listenSoftReset} from '#/state/events'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
@@ -47,10 +41,8 @@ export function FeedPage({
|
||||
renderEndOfFeed?: () => JSX.Element
|
||||
}) {
|
||||
const {hasSession} = useSession()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation()
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
const queryClient = useQueryClient()
|
||||
const {openComposer} = useComposerControls()
|
||||
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
|
||||
@@ -99,63 +91,6 @@ export function FeedPage({
|
||||
setHasNew(false)
|
||||
}, [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 (
|
||||
<View testID={testID} style={s.h100pct}>
|
||||
<MainScrollProvider>
|
||||
@@ -171,7 +106,6 @@ export function FeedPage({
|
||||
onHasNew={setHasNew}
|
||||
renderEmptyState={renderEmptyState}
|
||||
renderEndOfFeed={renderEndOfFeed}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
headerOffset={headerOffset}
|
||||
/>
|
||||
</MainScrollProvider>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {HomeHeaderLayout} from './HomeHeaderLayout'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
@@ -11,16 +10,6 @@ import {usePalette} from '#/lib/hooks/usePalette'
|
||||
|
||||
export function HomeHeader(
|
||||
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 {feeds, hasPinnedCustom} = usePinnedFeedsInfos()
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
|
||||
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
|
||||
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}) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
@@ -20,6 +29,7 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
|
||||
const pal = usePalette('default')
|
||||
const {headerMinimalShellTransform} = useMinimalShellMode()
|
||||
const {headerHeight} = useShellLayout()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
// @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 => {
|
||||
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}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 8,
|
||||
marginTop: 8,
|
||||
width: '100%',
|
||||
},
|
||||
tabBar: {
|
||||
// @ts-ignore Web only
|
||||
position: 'sticky',
|
||||
@@ -42,7 +84,7 @@ const styles = StyleSheet.create({
|
||||
left: 'calc(50% - 300px)',
|
||||
width: 600,
|
||||
top: 0,
|
||||
flexDirection: 'row',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
|
||||
@@ -103,7 +103,6 @@ const styles = StyleSheet.create({
|
||||
right: 0,
|
||||
top: 0,
|
||||
flexDirection: 'column',
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {PressableWithHover} from '../util/PressableWithHover'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {DraggableScrollView} from './DraggableScrollView'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
export interface TabBarProps {
|
||||
testID?: string
|
||||
@@ -15,6 +16,10 @@ export interface TabBarProps {
|
||||
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({
|
||||
testID,
|
||||
selectedPage,
|
||||
@@ -25,6 +30,7 @@ export function TabBar({
|
||||
}: TabBarProps) {
|
||||
const pal = usePalette('default')
|
||||
const scrollElRef = useRef<ScrollView>(null)
|
||||
const itemRefs = useRef<Array<Element>>([])
|
||||
const [itemXs, setItemXs] = useState<number[]>([])
|
||||
const indicatorStyle = useMemo(
|
||||
() => ({borderBottomColor: indicatorColor || pal.colors.link}),
|
||||
@@ -33,12 +39,58 @@ export function TabBar({
|
||||
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
|
||||
|
||||
// scrolls to the selected item when the page changes
|
||||
useEffect(() => {
|
||||
scrollElRef.current?.scrollTo({
|
||||
x:
|
||||
(itemXs[selectedPage] || 0) - styles.contentContainer.paddingHorizontal,
|
||||
})
|
||||
if (isNative) {
|
||||
// On native, the primary interaction is swiping.
|
||||
// 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])
|
||||
|
||||
const onPressItem = useCallback(
|
||||
@@ -78,6 +130,7 @@ export function TabBar({
|
||||
<PressableWithHover
|
||||
testID={`${testID}-selector-${i}`}
|
||||
key={`${item}-${i}`}
|
||||
ref={node => (itemRefs.current[i] = node)}
|
||||
onLayout={e => onItemLayout(e, i)}
|
||||
style={styles.item}
|
||||
hoverStyle={pal.viewLight}
|
||||
@@ -94,6 +147,7 @@ export function TabBar({
|
||||
)
|
||||
})}
|
||||
</DraggableScrollView>
|
||||
<View style={[pal.border, styles.outerBottomBorder]} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -117,6 +171,13 @@ const desktopStyles = StyleSheet.create({
|
||||
borderBottomWidth: 3,
|
||||
borderBottomColor: 'transparent',
|
||||
},
|
||||
outerBottomBorder: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -1,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const mobileStyles = StyleSheet.create({
|
||||
@@ -137,4 +198,11 @@ const mobileStyles = StyleSheet.create({
|
||||
borderBottomWidth: 3,
|
||||
borderBottomColor: 'transparent',
|
||||
},
|
||||
outerBottomBorder: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -1,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -94,6 +94,8 @@ export function PostThreadItem({
|
||||
if (richText && moderation) {
|
||||
return (
|
||||
<PostThreadItemLoaded
|
||||
// Safeguard from clobbering per-post state below:
|
||||
key={postShadowed.uri}
|
||||
post={postShadowed}
|
||||
prevPost={prevPost}
|
||||
nextPost={nextPost}
|
||||
|
||||
@@ -70,6 +70,8 @@ export function FeedItem({
|
||||
if (richText && moderation) {
|
||||
return (
|
||||
<FeedItemInner
|
||||
// Safeguard from clobbering per-post state below:
|
||||
key={postShadowed.uri}
|
||||
post={postShadowed}
|
||||
record={record}
|
||||
reason={reason}
|
||||
|
||||
@@ -20,12 +20,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
const setMode = useSetMinimalShellMode()
|
||||
const startDragOffset = useSharedValue<number | null>(null)
|
||||
const startMode = useSharedValue<number | null>(null)
|
||||
const didJustRestoreScroll = useSharedValue<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb) {
|
||||
return listenToForcedWindowScroll(() => {
|
||||
startDragOffset.value = null
|
||||
startMode.value = null
|
||||
didJustRestoreScroll.value = true
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -86,6 +88,11 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
mode.value = newValue
|
||||
}
|
||||
} 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.
|
||||
// Instead, show/hide immediately based on whether we're scrolling up or down.
|
||||
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 (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
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 {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
@@ -151,6 +151,7 @@ type Props = {
|
||||
testID?: string
|
||||
accessibilityLabel?: string
|
||||
accessibilityHint?: string
|
||||
triggerStyle?: ViewStyle
|
||||
}
|
||||
|
||||
/* The `NativeDropdown` function uses native iOS and Android dropdown menus.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
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 {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
@@ -53,6 +53,7 @@ type Props = {
|
||||
testID?: string
|
||||
accessibilityLabel?: string
|
||||
accessibilityHint?: string
|
||||
triggerStyle?: ViewStyle
|
||||
}
|
||||
|
||||
export function NativeDropdown({
|
||||
@@ -61,6 +62,7 @@ export function NativeDropdown({
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
accessibilityHint,
|
||||
triggerStyle,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
@@ -120,7 +122,8 @@ export function NativeDropdown({
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityHint={accessibilityHint}
|
||||
onPress={() => setOpen(o => !o)}
|
||||
hitSlop={HITSLOP_10}>
|
||||
hitSlop={HITSLOP_10}
|
||||
style={triggerStyle}>
|
||||
{children}
|
||||
</Pressable>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
@@ -22,12 +22,14 @@ export function Typography() {
|
||||
<Text style={[a.text_2xs]}>atoms.text_2xs</Text>
|
||||
|
||||
<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`}
|
||||
/>
|
||||
<RichText
|
||||
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`}
|
||||
style={[a.text_xl]}
|
||||
/>
|
||||
|
||||
@@ -55,6 +55,8 @@ export const Composer = observer(function ComposerImpl({
|
||||
onPost={state.onPost}
|
||||
quote={state.quote}
|
||||
mention={state.mention}
|
||||
text={state.text}
|
||||
imageUris={state.imageUris}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
||||
import {
|
||||
EmojiPicker,
|
||||
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
|
||||
|
||||
@@ -69,6 +69,7 @@ export function Composer({}: {winHeight: number}) {
|
||||
onPost={state.onPost}
|
||||
mention={state.mention}
|
||||
openPicker={onOpenPicker}
|
||||
text={state.text}
|
||||
/>
|
||||
</Animated.View>
|
||||
<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"
|
||||
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:
|
||||
version "14.8.2"
|
||||
resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-14.8.2.tgz#e0bbed2293265834d21a1c58d3a5f8d265bd04ae"
|
||||
|
||||
Reference in New Issue
Block a user