Merge branch 'bluesky-social:main' into main

zio/stable
Jan-Olof Eriksson 2024-02-29 11:55:03 +02:00 committed by GitHub
commit 963a44ab87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
131 changed files with 7094 additions and 1712 deletions

View File

@ -26,9 +26,9 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version-file: .nvmrc
cache: yarn cache: yarn
- name: 🔨 Setup EAS - name: 🔨 Setup EAS
@ -49,10 +49,14 @@ jobs:
- name: ⚙️ Install dependencies - name: ⚙️ Install dependencies
run: yarn install run: yarn install
- name: 🔤 Compile translations
run: yarn intl:build
- name: ✏️ Write environment variables - name: ✏️ Write environment variables
run: | run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env echo "${{ secrets.ENV_TOKEN }}" > .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json echo "$json" > google-services.json
- name: 🏗️ EAS Build - name: 🏗️ EAS Build
run: yarn use-build-number eas build -p android --profile production --local --output build.aab --non-interactive run: yarn use-build-number eas build -p android --profile production --local --output build.aab --non-interactive

View File

@ -28,9 +28,9 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version-file: .nvmrc
cache: yarn cache: yarn
- name: 🔨 Setup EAS - name: 🔨 Setup EAS
@ -60,6 +60,9 @@ jobs:
# change unless the yarn version changes as well. # change unless the yarn version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }} key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
- name: 🔤 Compile translations
run: yarn intl:build
- name: ✏️ Write environment variables - name: ✏️ Write environment variables
run: | run: |
echo "${{ secrets.ENV_TOKEN }}" > .env echo "${{ secrets.ENV_TOKEN }}" > .env

View File

@ -32,12 +32,12 @@ jobs:
name: Run tests name: Run tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Install node 18
uses: actions/setup-node@v4
with:
node-version: 18
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Yarn install - name: Yarn install
uses: Wandalen/wretry.action@master uses: Wandalen/wretry.action@master
with: with:

1
.nvmrc 100644
View File

@ -0,0 +1 @@
18

View File

@ -23,7 +23,7 @@ COPY . .
RUN mkdir --parents $NVM_DIR && \ RUN mkdir --parents $NVM_DIR && \
wget \ wget \
--output-document=/tmp/nvm-install.sh \ --output-document=/tmp/nvm-install.sh \
https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh && \ https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh && \
bash /tmp/nvm-install.sh bash /tmp/nvm-install.sh
RUN \. "$NVM_DIR/nvm.sh" && \ RUN \. "$NVM_DIR/nvm.sh" && \

View File

@ -65,8 +65,6 @@ If you discover any security issues, please send an email to security@bsky.app.
Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party can be as seamless as first-party through custom feeds, federated services, clients, and more. Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party can be as seamless as first-party through custom feeds, federated services, clients, and more.
If you're a developer interested in building on atproto, we'd love to email you a Bluesky invite code. Simply share your GitHub (or similar) profile with us via [this form](https://forms.gle/BF21oxVNZiDjDhXF9).
## License (MIT) ## License (MIT)
See [./LICENSE](./LICENSE) for the full license. See [./LICENSE](./LICENSE) for the full license.

View File

@ -65,6 +65,9 @@ module.exports = function (config) {
...SPLASH_CONFIG, ...SPLASH_CONFIG,
dark: DARK_SPLASH_CONFIG, dark: DARK_SPLASH_CONFIG,
}, },
entitlements: {
'com.apple.security.application-groups': 'group.app.bsky',
},
}, },
androidStatusBar: { androidStatusBar: {
barStyle: 'dark-content', barStyle: 'dark-content',
@ -89,6 +92,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,9 +144,27 @@ module.exports = function (config) {
}, },
], ],
'./plugins/withAndroidManifestPlugin.js', './plugins/withAndroidManifestPlugin.js',
'./plugins/shareExtension/withShareExtensions.js',
].filter(Boolean), ].filter(Boolean),
extra: { extra: {
eas: { eas: {
build: {
experimental: {
ios: {
appExtensions: [
{
targetName: 'Share-with-Bluesky',
bundleIdentifier: 'xyz.blueskyweb.app.Share-with-Bluesky',
entitlements: {
'com.apple.security.application-groups': [
'group.app.bsky',
],
},
},
],
},
},
},
projectId: '55bd077a-d905-4184-9c7f-94789ba0f302', projectId: '55bd077a-d905-4184-9c7f-94789ba0f302',
}, },
}, },

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 300 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 422 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 269 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 465 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@ -6,9 +6,9 @@ To build the SPA bundle (`bundle.web.js`), first get a JavaScript development
environment set up. Either follow the top-level README, or something quick environment set up. Either follow the top-level README, or something quick
like: like:
# install nodejs 18 (specifically) # install nodejs
nvm install 18 nvm install
nvm use 18 nvm use
npm install --global yarn npm install --global yarn
# setup tools and deps (in top level of this repo) # setup tools and deps (in top level of this repo)

View File

@ -202,6 +202,7 @@ func serve(cctx *cli.Context) error {
e.GET("/support/tos", server.WebGeneric) e.GET("/support/tos", server.WebGeneric)
e.GET("/support/community-guidelines", server.WebGeneric) e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric) e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGeneric)
// profile endpoints; only first populates info // profile endpoints; only first populates info
e.GET("/profile/:handleOrDID", server.WebProfile) e.GET("/profile/:handleOrDID", server.WebProfile)

View File

@ -205,6 +205,11 @@
[data-tooltip]:hover::before { [data-tooltip]:hover::before {
display:block; display:block;
} }
/* NativeDropdown component */
.nativeDropdown-item:focus {
outline: none;
}
</style> </style>
{% include "scripts.html" %} {% include "scripts.html" %}
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">

View File

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

View File

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

View File

@ -0,0 +1,153 @@
import UIKit
class ShareViewController: UIViewController {
// This allows other forks to use this extension while also changing their
// scheme.
let appScheme = Bundle.main.object(forInfoDictionaryKey: "MainAppScheme") as? String ?? "bluesky"
//
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
let attachments = extensionItem.attachments,
let firstAttachment = extensionItem.attachments?.first
else {
self.completeRequest()
return
}
Task {
if firstAttachment.hasItemConformingToTypeIdentifier("public.text") {
await self.handleText(item: firstAttachment)
} else if firstAttachment.hasItemConformingToTypeIdentifier("public.url") {
await self.handleUrl(item: firstAttachment)
} else if firstAttachment.hasItemConformingToTypeIdentifier("public.image") {
await self.handleImages(items: attachments)
} else {
self.completeRequest()
}
}
}
private func handleText(item: NSItemProvider) async -> Void {
do {
if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String {
if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)")
{
_ = self.openURL(url)
}
}
self.completeRequest()
} catch {
self.completeRequest()
}
}
private func handleUrl(item: NSItemProvider) async -> Void {
do {
if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL {
if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)")
{
_ = self.openURL(url)
}
}
self.completeRequest()
} catch {
self.completeRequest()
}
}
private func handleImages(items: [NSItemProvider]) async -> Void {
let firstFourItems: [NSItemProvider]
if items.count < 4 {
firstFourItems = items
} else {
firstFourItems = Array(items[0...3])
}
var valid = true
var imageUris = ""
for (index, item) in firstFourItems.enumerated() {
var imageUriInfo: String? = nil
do {
if let dataUri = try await item.loadItem(forTypeIdentifier: "public.image") as? URL {
// We need to duplicate this image, since we don't have access to the outgoing temp directory
// We also will get the image dimensions here, sinze RN makes it difficult to get those dimensions for local files
let data = try Data(contentsOf: dataUri)
let image = UIImage(data: data)
imageUriInfo = self.saveImageWithInfo(image)
} else if let image = try await item.loadItem(forTypeIdentifier: "public.image") as? UIImage {
imageUriInfo = self.saveImageWithInfo(image)
}
} catch {
valid = false
}
if let imageUriInfo = imageUriInfo {
imageUris.append(imageUriInfo)
if index < items.count - 1 {
imageUris.append(",")
}
} else {
valid = false
}
}
if valid,
let encoded = imageUris.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
let url = URL(string: "\(self.appScheme)://intent/compose?imageUris=\(encoded)")
{
_ = self.openURL(url)
}
self.completeRequest()
}
private func saveImageWithInfo(_ image: UIImage?) -> String? {
guard let image = image else {
return nil
}
do {
// Saving this file to the bundle group's directory lets us access it from
// inside of the app. Otherwise, we wouldn't have access even though the
// extension does.
if let dir = FileManager()
.containerURL(
forSecurityApplicationGroupIdentifier: "group.app.bsky")
{
let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg"
if let newUri = URL(string: filePath),
let jpegData = image.jpegData(compressionQuality: 1)
{
try jpegData.write(to: newUri)
return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)"
}
}
return nil
} catch {
return nil
}
}
private func completeRequest() -> Void {
self.extensionContext?.completeRequest(returningItems: nil)
}
@objc func openURL(_ url: URL) -> Bool {
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
return application.perform(#selector(openURL(_:)), with: url) != nil
}
responder = responder?.next
}
return false
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,119 @@
package xyz.blueskyweb.app.exporeceiveandroidintents
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.core.net.toUri
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
import java.io.FileOutputStream
import java.net.URLEncoder
class ExpoReceiveAndroidIntentsModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoReceiveAndroidIntents")
OnNewIntent {
handleIntent(it)
}
}
private fun handleIntent(intent: Intent?) {
if(appContext.currentActivity == null || intent == null) return
if (intent.action == Intent.ACTION_SEND) {
if (intent.type == "text/plain") {
handleTextIntent(intent)
} else if (intent.type.toString().startsWith("image/")) {
handleImageIntent(intent)
}
} else if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
if (intent.type.toString().startsWith("image/")) {
handleImagesIntent(intent)
}
}
}
private fun handleTextIntent(intent: Intent) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
val encoded = URLEncoder.encode(it, "UTF-8")
"bluesky://intent/compose?text=${encoded}".toUri().let { uri ->
val newIntent = Intent(Intent.ACTION_VIEW, uri)
appContext.currentActivity?.startActivity(newIntent)
}
}
}
private fun handleImageIntent(intent: Intent) {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
if (uri == null) return
handleImageIntents(listOf(uri))
}
private fun handleImagesIntent(intent: Intent) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.let {
handleImageIntents(it.filterIsInstance<Uri>().take(4))
}
} else {
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let {
handleImageIntents(it.filterIsInstance<Uri>().take(4))
}
}
}
private fun handleImageIntents(uris: List<Uri>) {
var allParams = ""
uris.forEachIndexed { index, uri ->
val info = getImageInfo(uri)
val params = buildUriData(info)
allParams = "${allParams}${params}"
if (index < uris.count() - 1) {
allParams = "${allParams},"
}
}
val encoded = URLEncoder.encode(allParams, "UTF-8")
"bluesky://intent/compose?imageUris=${encoded}".toUri().let {
val newIntent = Intent(Intent.ACTION_VIEW, it)
appContext.currentActivity?.startActivity(newIntent)
}
}
private fun getImageInfo(uri: Uri): Map<String, Any> {
val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
// We have to save this so that we can access it later when uploading the image.
// createTempFile will automatically place a unique string between "img" and "temp.jpeg"
val file = File.createTempFile("img", "temp.jpeg", appContext.currentActivity?.cacheDir)
val out = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
out.flush()
out.close()
return mapOf(
"width" to bitmap.width,
"height" to bitmap.height,
"path" to file.path.toString()
)
}
// We will pas the width and height to the app here, since getting measurements
// on the RN side is a bit more involved, and we already have them here anyway.
private fun buildUriData(info: Map<String, Any>): String {
val path = info.getValue("path")
val width = info.getValue("width")
val height = info.getValue("height")
return "file://${path}|${width}|${height}"
}
}

View File

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

View File

@ -108,14 +108,26 @@ class RNUITextView: UIView {
fractionOfDistanceBetweenInsertionPoints: nil fractionOfDistanceBetweenInsertionPoints: nil
) )
var lastUpperBound: String.Index? = nil
for child in self.reactSubviews() { for child in self.reactSubviews() {
if let child = child as? RNUITextViewChild, let childText = child.text { if let child = child as? RNUITextViewChild, let childText = child.text {
let fullText = self.textView.attributedText.string let fullText = self.textView.attributedText.string
let range = fullText.range(of: childText)
// We want to skip over the children we have already checked, otherwise we could run into
// collisions of similar strings (i.e. links that get shortened to the same hostname but
// different paths)
let range = fullText.range(of: childText, options: [], range: (lastUpperBound ?? String.Index(utf16Offset: 0, in: fullText) )..<fullText.endIndex)
if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound { if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound {
if charIndex >= lowerBound.utf16Offset(in: fullText) && charIndex <= upperBound.utf16Offset(in: fullText) { let lowerOffset = lowerBound.utf16Offset(in: fullText)
let upperOffset = upperBound.utf16Offset(in: fullText)
if charIndex >= lowerOffset,
charIndex <= upperOffset
{
return child return child
} else {
lastUpperBound = upperBound
} }
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.69.0", "version": "1.70.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -36,14 +36,15 @@
"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.9.5", "@atproto/api": "^0.10.0",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
@ -109,6 +110,7 @@
"expo-image": "~1.10.3", "expo-image": "~1.10.3",
"expo-image-manipulator": "^11.8.0", "expo-image-manipulator": "^11.8.0",
"expo-image-picker": "~14.7.1", "expo-image-picker": "~14.7.1",
"expo-linking": "^6.2.2",
"expo-localization": "~14.8.2", "expo-localization": "~14.8.2",
"expo-media-library": "~15.9.1", "expo-media-library": "~15.9.1",
"expo-notifications": "~0.27.3", "expo-notifications": "~0.27.3",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,63 @@
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'
}
}
}
pbxProject.addTargetAttribute(
'DevelopmentTeam',
'B3LX46C5HS',
extensionName,
)
pbxProject.addTargetAttribute('DevelopmentTeam', 'B3LX46C5HS')
return config
})
}
module.exports = {withXcodeTarget}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -181,6 +181,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
const logoAnimations = const logoAnimations =
reduceMotion === true ? reducedLogoAnimation : logoAnimation reduceMotion === true ? reducedLogoAnimation : logoAnimation
// special off-spec color for dark mode
const logoBg = isDarkMode ? '#0F1824' : '#fff'
return ( return (
<View style={{flex: 1}} onLayout={onLayout}> <View style={{flex: 1}} onLayout={onLayout}>
@ -232,7 +234,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
}, },
]}> ]}>
<AnimatedLogo <AnimatedLogo
fill="#fff" fill={logoBg}
style={[{opacity: 0}, logoAnimations]} style={[{opacity: 0}, logoAnimations]}
/> />
</Animated.View> </Animated.View>
@ -253,7 +255,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
}, },
]}> ]}>
<AnimatedLogo fill="#fff" style={[logoAnimations]} /> <AnimatedLogo fill={logoBg} style={[logoAnimations]} />
</Animated.View> </Animated.View>
}> }>
{!isAnimationComplete && ( {!isAnimationComplete && (
@ -261,10 +263,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
style={[ style={[
StyleSheet.absoluteFillObject, StyleSheet.absoluteFillObject,
{ {
backgroundColor: isDarkMode backgroundColor: logoBg,
? // special off-spec color for dark mode
'#0F1824'
: '#fff',
}, },
]} ]}
/> />

View File

@ -1,3 +1,4 @@
import {web, native} from '#/alf/util/platform'
import * as tokens from '#/alf/tokens' import * as tokens from '#/alf/tokens'
export const atoms = { export const atoms = {
@ -113,6 +114,9 @@ export const atoms = {
flex_wrap: { flex_wrap: {
flexWrap: 'wrap', flexWrap: 'wrap',
}, },
flex_0: {
flex: web('0 0 auto') || (native(0) as number),
},
flex_1: { flex_1: {
flex: 1, flex: 1,
}, },

View File

@ -1,25 +1,25 @@
import {Platform} from 'react-native' import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
export function web(value: any) { export function web(value: any) {
return Platform.select({ if (isWeb) {
web: value, return value
}) }
} }
export function ios(value: any) { export function ios(value: any) {
return Platform.select({ if (isIOS) {
ios: value, return value
}) }
} }
export function android(value: any) { export function android(value: any) {
return Platform.select({ if (isAndroid) {
android: value, return value
}) }
} }
export function native(value: any) { export function native(value: any) {
return Platform.select({ if (isNative) {
native: value, return value
}) }
} }

View File

@ -3,7 +3,7 @@ import React from 'react'
import {useDialogStateContext} from '#/state/dialogs' import {useDialogStateContext} from '#/state/dialogs'
import { import {
DialogContextProps, DialogContextProps,
DialogControlProps, DialogControlRefProps,
DialogOuterProps, DialogOuterProps,
} from '#/components/Dialog/types' } from '#/components/Dialog/types'
@ -17,7 +17,7 @@ export function useDialogContext() {
export function useDialogControl(): DialogOuterProps['control'] { export function useDialogControl(): DialogOuterProps['control'] {
const id = React.useId() const id = React.useId()
const control = React.useRef<DialogControlProps>({ const control = React.useRef<DialogControlRefProps>({
open: () => {}, open: () => {},
close: () => {}, close: () => {},
}) })
@ -32,8 +32,13 @@ export function useDialogControl(): DialogOuterProps['control'] {
}, [id, activeDialogs]) }, [id, activeDialogs])
return { return {
id,
ref: control, ref: control,
open: () => control.current.open(), open: () => {
close: cb => control.current.close(cb), control.current.open()
},
close: cb => {
control.current.close(cb)
},
} }
} }

View File

@ -11,6 +11,8 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useTheme, atoms as a, flatten} from '#/alf' import {useTheme, atoms as a, flatten} from '#/alf'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {createInput} from '#/components/forms/TextField' import {createInput} from '#/components/forms/TextField'
import {logger} from '#/logger'
import {useDialogStateContext} from '#/state/dialogs'
import { import {
DialogOuterProps, DialogOuterProps,
@ -36,6 +38,7 @@ export function Outer({
const hasSnapPoints = !!sheetOptions.snapPoints const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const closeCallback = React.useRef<() => void>() const closeCallback = React.useRef<() => void>()
const {openDialogs} = useDialogStateContext()
/* /*
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet` * Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
@ -49,14 +52,15 @@ export function Outer({
const open = React.useCallback<DialogControlProps['open']>( const open = React.useCallback<DialogControlProps['open']>(
({index} = {}) => { ({index} = {}) => {
openDialogs.current.add(control.id)
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open" // can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
setOpenIndex(index || 0) setOpenIndex(index || 0)
}, },
[setOpenIndex], [setOpenIndex, openDialogs, control.id],
) )
const close = React.useCallback<DialogControlProps['close']>(cb => { const close = React.useCallback<DialogControlProps['close']>(cb => {
if (cb) { if (cb && typeof cb === 'function') {
closeCallback.current = cb closeCallback.current = cb
} }
sheet.current?.close() sheet.current?.close()
@ -74,13 +78,22 @@ export function Outer({
const onChange = React.useCallback( const onChange = React.useCallback(
(index: number) => { (index: number) => {
if (index === -1) { if (index === -1) {
closeCallback.current?.() try {
closeCallback.current = undefined closeCallback.current?.()
} catch (e: any) {
logger.error(`Dialog closeCallback failed`, {
message: e.message,
})
} finally {
closeCallback.current = undefined
}
openDialogs.current.delete(control.id)
onClose?.() onClose?.()
setOpenIndex(-1) setOpenIndex(-1)
} }
}, },
[onClose, setOpenIndex], [onClose, setOpenIndex, openDialogs, control.id],
) )
const context = React.useMemo(() => ({close}), [close]) const context = React.useMemo(() => ({close}), [close])

View File

@ -12,6 +12,7 @@ import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context' import {Context} from '#/components/Dialog/context'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {useDialogStateContext} from '#/state/dialogs'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context' export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types' export * from '#/components/Dialog/types'
@ -29,18 +30,21 @@ export function Outer({
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const [isOpen, setIsOpen] = React.useState(false) const [isOpen, setIsOpen] = React.useState(false)
const [isVisible, setIsVisible] = React.useState(true) const [isVisible, setIsVisible] = React.useState(true)
const {openDialogs} = useDialogStateContext()
const open = React.useCallback(() => { const open = React.useCallback(() => {
setIsOpen(true) setIsOpen(true)
}, [setIsOpen]) openDialogs.current.add(control.id)
}, [setIsOpen, openDialogs, control.id])
const close = React.useCallback(async () => { const close = React.useCallback(async () => {
setIsVisible(false) setIsVisible(false)
await new Promise(resolve => setTimeout(resolve, 150)) await new Promise(resolve => setTimeout(resolve, 150))
setIsOpen(false) setIsOpen(false)
setIsVisible(true) setIsVisible(true)
openDialogs.current.delete(control.id)
onClose?.() onClose?.()
}, [onClose, setIsOpen]) }, [onClose, setIsOpen, openDialogs, control.id])
useImperativeHandle( useImperativeHandle(
control.ref, control.ref,
@ -188,9 +192,9 @@ export function Close() {
<Button <Button
size="small" size="small"
variant="ghost" variant="ghost"
color="primary" color="secondary"
shape="round" shape="round"
onPress={close} onPress={() => close()}
label={_(msg`Close active dialog`)}> label={_(msg`Close active dialog`)}>
<ButtonIcon icon={X} size="md" /> <ButtonIcon icon={X} size="md" />
</Button> </Button>

View File

@ -6,8 +6,26 @@ import {ViewStyleProp} from '#/alf'
type A11yProps = Required<AccessibilityProps> type A11yProps = Required<AccessibilityProps>
/**
* Mutated by useImperativeHandle to provide a public API for controlling the
* dialog. The methods here will actually become the handlers defined within
* the `Dialog.Outer` component.
*/
export type DialogControlRefProps = {
open: (options?: DialogControlOpenOptions) => void
close: (callback?: () => void) => void
}
/**
* The return type of the useDialogControl hook.
*/
export type DialogControlProps = DialogControlRefProps & {
id: string
ref: React.RefObject<DialogControlRefProps>
}
export type DialogContextProps = { export type DialogContextProps = {
close: () => void close: DialogControlProps['close']
} }
export type DialogControlOpenOptions = { export type DialogControlOpenOptions = {
@ -20,15 +38,8 @@ export type DialogControlOpenOptions = {
index?: number index?: number
} }
export type DialogControlProps = {
open: (options?: DialogControlOpenOptions) => void
close: (callback?: () => void) => void
}
export type DialogOuterProps = { export type DialogOuterProps = {
control: { control: DialogControlProps
ref: React.RefObject<DialogControlProps>
} & DialogControlProps
onClose?: () => void onClose?: () => void
nativeOptions?: { nativeOptions?: {
sheet?: Omit<BottomSheetProps, 'children'> sheet?: Omit<BottomSheetProps, 'children'>

View File

@ -89,7 +89,7 @@ export function Cancel({
color="secondary" color="secondary"
size="small" size="small"
label={_(msg`Cancel`)} label={_(msg`Cancel`)}
onPress={close}> onPress={() => close()}>
{children} {children}
</Button> </Button>
) )

View File

@ -1,11 +1,15 @@
import React from 'react' import React from 'react'
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api' import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {atoms as a, TextStyleProp, flatten} from '#/alf' 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 {isNative} from '#/platform/detection'
import {useInteractionState} from '#/components/hooks/useInteractionState'
const WORD_WRAP = {wordWrap: 1} const WORD_WRAP = {wordWrap: 1}
@ -15,37 +19,25 @@ export function RichText({
style, style,
numberOfLines, numberOfLines,
disableLinks, disableLinks,
resolveFacets = false,
selectable, selectable,
enableTags = false,
authorHandle,
}: TextStyleProp & }: TextStyleProp &
Pick<TextProps, 'selectable'> & { Pick<TextProps, 'selectable'> & {
value: RichTextAPI | string value: RichTextAPI | string
testID?: string testID?: string
numberOfLines?: number numberOfLines?: number
disableLinks?: boolean disableLinks?: boolean
resolveFacets?: boolean enableTags?: boolean
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) {
@ -85,6 +77,7 @@ export function RichText({
for (const segment of richText.segments()) { for (const segment of richText.segments()) {
const link = segment.link const link = segment.link
const mention = segment.mention const mention = segment.mention
const tag = segment.tag
if ( if (
mention && mention &&
AppBskyRichtextFacet.validateMention(mention).success && AppBskyRichtextFacet.validateMention(mention).success &&
@ -118,6 +111,21 @@ export function RichText({
</InlineLink>, </InlineLink>,
) )
} }
} else if (
!disableLinks &&
enableTags &&
tag &&
AppBskyRichtextFacet.validateTag(tag).success
) {
els.push(
<RichTextTag
key={key}
text={segment.text}
style={styles}
selectable={selectable}
authorHandle={authorHandle}
/>,
)
} else { } else {
els.push(segment.text) els.push(segment.text)
} }
@ -136,3 +144,79 @@ export function RichText({
</Text> </Text>
) )
} }
function RichTextTag({
text: tag,
style,
selectable,
authorHandle,
}: {
text: string
selectable?: boolean
authorHandle?: string
} & TextStyleProp) {
const t = useTheme()
const {_} = useLingui()
const control = useTagMenuControl()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const open = React.useCallback(() => {
control.open()
}, [control])
/*
* N.B. On web, this is wrapped in another pressable comopnent with a11y
* labels, etc. That's why only some of these props are applied here.
*/
return (
<React.Fragment>
<TagMenu control={control} tag={tag} authorHandle={authorHandle}>
<Text
selectable={selectable}
{...native({
accessibilityLabel: _(msg`Hashtag: ${tag}`),
accessibilityHint: _(msg`Click here to open tag menu for ${tag}`),
accessibilityRole: isNative ? 'button' : undefined,
onPress: open,
onPressIn: onPressIn,
onPressOut: onPressOut,
})}
{...web({
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
})}
// @ts-ignore
onFocus={onFocus}
onBlur={onBlur}
style={[
style,
{
pointerEvents: 'auto',
color: t.palette.primary_500,
},
web({
cursor: 'pointer',
}),
(hovered || focused || pressed) && {
...web({outline: 0}),
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
},
]}>
{tag}
</Text>
</TagMenu>
</React.Fragment>
)
}

View File

@ -0,0 +1,279 @@
import React from 'react'
import {View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {atoms as a, native, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {Button, ButtonText} from '#/components/Button'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Divider} from '#/components/Divider'
import {Link} from '#/components/Link'
import {makeSearchLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {
usePreferencesQuery,
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {Loader} from '#/components/Loader'
import {isInvalidHandle} from '#/lib/strings/handles'
export function useTagMenuControl() {
return Dialog.useDialogControl()
}
export function TagMenu({
children,
control,
tag,
authorHandle,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
tag: string
authorHandle?: string
}>) {
const {_} = useLingui()
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
const {isLoading: isPreferencesLoading, data: preferences} =
usePreferencesQuery()
const {
mutateAsync: upsertMutedWord,
variables: optimisticUpsert,
reset: resetUpsert,
} = useUpsertMutedWordsMutation()
const {
mutateAsync: removeMutedWord,
variables: optimisticRemove,
reset: resetRemove,
} = useRemoveMutedWordMutation()
const sanitizedTag = tag.replace(/^#/, '')
const isMuted = Boolean(
(preferences?.mutedWords?.find(
m => m.value === sanitizedTag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
m => m.value === sanitizedTag && m.targets.includes('tag'),
)) &&
!(optimisticRemove?.value === sanitizedTag),
)
return (
<>
{children}
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.Inner label={_(msg`Tag menu: ${tag}`)}>
{isPreferencesLoading ? (
<View style={[a.w_full, a.align_center]}>
<Loader size="lg" />
</View>
) : (
<>
<View
style={[
a.rounded_md,
a.border,
a.mb_md,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}>
<Link
label={_(msg`Search for all posts with tag ${tag}`)}
to={makeSearchLink({query: tag})}
onPress={e => {
e.preventDefault()
control.close(() => {
// @ts-ignore :ron_swanson: "I know more than you"
navigation.navigate('SearchTab', {
screen: 'Search',
params: {
q: tag,
},
})
})
return false
}}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Search size="lg" style={[t.atoms.text_contrast_medium]} />
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
<Trans>
See{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{tag}
</Text>{' '}
posts
</Trans>
</Text>
</View>
</Link>
{authorHandle && !isInvalidHandle(authorHandle) && (
<>
<Divider />
<Link
label={_(
msg`Search for all posts by @${authorHandle} with tag ${tag}`,
)}
to={makeSearchLink({query: tag, from: authorHandle})}
onPress={e => {
e.preventDefault()
control.close(() => {
// @ts-ignore :ron_swanson: "I know more than you"
navigation.navigate('SearchTab', {
screen: 'Search',
params: {
q:
tag +
(authorHandle ? ` from:${authorHandle}` : ''),
},
})
})
return false
}}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Person
size="lg"
style={[t.atoms.text_contrast_medium]}
/>
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
<Trans>
See{' '}
<Text
style={[a.text_md, a.font_bold, t.atoms.text]}>
{tag}
</Text>{' '}
posts by this user
</Trans>
</Text>
</View>
</Link>
</>
)}
{preferences ? (
<>
<Divider />
<Button
label={
isMuted
? _(msg`Unmute all ${tag} posts`)
: _(msg`Mute all ${tag} posts`)
}
onPress={() => {
control.close(() => {
if (isMuted) {
resetUpsert()
removeMutedWord({
value: sanitizedTag,
targets: ['tag'],
})
} else {
resetRemove()
upsertMutedWord([
{value: sanitizedTag, targets: ['tag']},
])
}
})
}}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Mute
size="lg"
style={[t.atoms.text_contrast_medium]}
/>
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
{isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{tag}
</Text>{' '}
<Trans>posts</Trans>
</Text>
</View>
</Button>
</>
) : null}
</View>
<Button
label={_(msg`Close this dialog`)}
size="small"
variant="ghost"
color="secondary"
onPress={() => control.close()}>
<ButtonText>Cancel</ButtonText>
</Button>
</>
)}
</Dialog.Inner>
</Dialog.Outer>
</>
)
}

View File

@ -0,0 +1,136 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {isInvalidHandle} from '#/lib/strings/handles'
import {EventStopper} from '#/view/com/util/EventStopper'
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
import {NavigationProp} from '#/lib/routes/types'
import {
usePreferencesQuery,
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {enforceLen} from '#/lib/strings/helpers'
import {web} from '#/alf'
export function useTagMenuControl() {}
export function TagMenu({
children,
tag,
authorHandle,
}: React.PropsWithChildren<{
tag: string
authorHandle?: string
}>) {
const sanitizedTag = tag.replace(/^#/, '')
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {data: preferences} = usePreferencesQuery()
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
useUpsertMutedWordsMutation()
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
useRemoveMutedWordMutation()
const isMuted = Boolean(
(preferences?.mutedWords?.find(
m => m.value === sanitizedTag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
m => m.value === sanitizedTag && m.targets.includes('tag'),
)) &&
!(optimisticRemove?.value === sanitizedTag),
)
const truncatedTag = enforceLen(tag, 15, true, 'middle')
const dropdownItems = React.useMemo(() => {
return [
{
label: _(msg`See ${truncatedTag} posts`),
onPress() {
navigation.navigate('Search', {
q: tag,
})
},
testID: 'tagMenuSearch',
icon: {
ios: {
name: 'magnifyingglass',
},
android: '',
web: 'magnifying-glass',
},
},
authorHandle &&
!isInvalidHandle(authorHandle) && {
label: _(msg`See ${truncatedTag} posts by user`),
onPress() {
navigation.navigate({
name: 'Search',
params: {
q: tag + (authorHandle ? ` from:${authorHandle}` : ''),
},
})
},
testID: 'tagMenuSeachByUser',
icon: {
ios: {
name: 'magnifyingglass',
},
android: '',
web: ['far', 'user'],
},
},
preferences && {
label: 'separator',
},
preferences && {
label: isMuted
? _(msg`Unmute ${truncatedTag}`)
: _(msg`Mute ${truncatedTag}`),
onPress() {
if (isMuted) {
removeMutedWord({value: sanitizedTag, targets: ['tag']})
} else {
upsertMutedWord([{value: sanitizedTag, targets: ['tag']}])
}
},
testID: 'tagMenuMute',
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_menu_sort_alphabetically',
web: isMuted ? 'eye' : ['far', 'eye-slash'],
},
},
].filter(Boolean)
}, [
_,
authorHandle,
isMuted,
navigation,
preferences,
tag,
truncatedTag,
sanitizedTag,
upsertMutedWord,
removeMutedWord,
])
return (
<EventStopper>
<NativeDropdown
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
accessibilityHint=""
// @ts-ignore
items={dropdownItems}
triggerStyle={web({
textAlign: 'left',
})}>
{children}
</NativeDropdown>
</EventStopper>
)
}

View File

@ -0,0 +1,29 @@
import React from 'react'
import * as Dialog from '#/components/Dialog'
type Control = Dialog.DialogOuterProps['control']
type ControlsContext = {
mutedWordsDialogControl: Control
}
const ControlsContext = React.createContext({
mutedWordsDialogControl: {} as Control,
})
export function useGlobalDialogsControlContext() {
return React.useContext(ControlsContext)
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const mutedWordsDialogControl = Dialog.useDialogControl()
const ctx = React.useMemo(
() => ({mutedWordsDialogControl}),
[mutedWordsDialogControl],
)
return (
<ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider>
)
}

View File

@ -0,0 +1,340 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyActorDefs} from '@atproto/api'
import {
usePreferencesQuery,
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {isNative} from '#/platform/detection'
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'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
import {Divider} from '#/components/Divider'
import {Loader} from '#/components/Loader'
import {logger} from '#/logger'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import * as Prompt from '#/components/Prompt'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
export function MutedWordsDialog() {
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<MutedWordsInner control={control} />
</Dialog.Outer>
)
}
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {
isLoading: isPreferencesLoading,
data: preferences,
error: preferencesError,
} = usePreferencesQuery()
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
const [field, setField] = React.useState('')
const [options, setOptions] = React.useState(['content'])
const [_error, setError] = React.useState('')
const submit = React.useCallback(async () => {
const value = field.trim()
const targets = ['tag', options.includes('content') && 'content'].filter(
Boolean,
) as AppBskyActorDefs.MutedWord['targets']
if (!value || !targets.length) return
try {
await addMutedWord([{value, targets}])
setField('')
} catch (e: any) {
logger.error(`Failed to save muted word`, {message: e.message})
setError(e.message)
}
}, [field, options, addMutedWord, setField])
return (
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
<Text
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
<Trans>Add muted words and tags</Trans>
</Text>
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Posts can be muted based on their text, their tags, or both.
</Trans>
</Text>
<View style={[a.pb_lg]}>
<Dialog.Input
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
label={_(msg`Enter a word or tag`)}
placeholder={_(msg`Enter a word or tag`)}
value={field}
onChangeText={setField}
onSubmitEditing={submit}
/>
<Toggle.Group
label={_(msg`Toggle between muted word options.`)}
type="radio"
values={options}
onChange={setOptions}>
<View
style={[
a.pt_sm,
a.pb_md,
a.flex_row,
a.align_center,
a.gap_sm,
a.flex_wrap,
]}>
<Toggle.Item
label={_(msg`Mute this word in post text and tags`)}
name="content"
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
<TargetToggle>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.Label>
<Trans>Mute in text & tags</Trans>
</Toggle.Label>
</View>
<PageText size="sm" />
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word in tags only`)}
name="tag"
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
<TargetToggle>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.Label>
<Trans>Mute in tags only</Trans>
</Toggle.Label>
</View>
<Hashtag size="sm" />
</TargetToggle>
</Toggle.Item>
<Button
disabled={isPending || !field}
label={_(msg`Add mute word for configured settings`)}
size="small"
color="primary"
variant="solid"
style={[!gtMobile && [a.w_full, a.flex_0]]}
onPress={submit}>
<ButtonText>
<Trans>Add</Trans>
</ButtonText>
<ButtonIcon icon={isPending ? Loader : Plus} />
</Button>
</View>
</Toggle.Group>
<Text
style={[
a.text_sm,
a.italic,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
We recommend avoiding common words that appear in many posts, since
it can result in no posts being shown.
</Trans>
</Text>
</View>
<Divider />
<View style={[a.pt_2xl]}>
<Text
style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
<Trans>Your muted words</Trans>
</Text>
{isPreferencesLoading ? (
<Loader />
) : preferencesError || !preferences ? (
<View
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>
We're sorry, but we weren't able to load your muted words at
this time. Please try again.
</Trans>
</Text>
</View>
) : preferences.mutedWords.length ? (
[...preferences.mutedWords]
.reverse()
.map((word, i) => (
<MutedWordRow
key={word.value + i}
word={word}
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
/>
))
) : (
<View
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>You haven't muted any words or tags yet</Trans>
</Text>
</View>
)}
</View>
{isNative && <View style={{height: 20}} />}
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function MutedWordRow({
style,
word,
}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) {
const t = useTheme()
const {_} = useLingui()
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
const control = Prompt.usePromptControl()
const remove = React.useCallback(async () => {
control.close()
removeMutedWord(word)
}, [removeMutedWord, word, control])
return (
<>
<Prompt.Outer control={control}>
<Prompt.Title>
<Trans>Are you sure?</Trans>
</Prompt.Title>
<Prompt.Description>
<Trans>
This will delete {word.value} from your muted words. You can always
add it back later.
</Trans>
</Prompt.Description>
<Prompt.Actions>
<Prompt.Cancel>
<ButtonText>
<Trans>Nevermind</Trans>
</ButtonText>
</Prompt.Cancel>
<Prompt.Action onPress={remove}>
<ButtonText>
<Trans>Remove</Trans>
</ButtonText>
</Prompt.Action>
</Prompt.Actions>
</Prompt.Outer>
<View
style={[
a.py_md,
a.px_lg,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_md,
a.gap_md,
style,
]}>
<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>
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
{word.targets.map(target => (
<View
key={target}
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
<Text
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
</Text>
</View>
))}
<Button
label={_(msg`Remove mute word from your list`)}
size="tiny"
shape="round"
variant="ghost"
color="secondary"
onPress={() => control.open()}
style={[a.ml_sm]}>
<ButtonIcon icon={isPending ? Loader : X} />
</Button>
</View>
</View>
</>
)
}
function TargetToggle({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const ctx = Toggle.useItemContext()
const {gtMobile} = useBreakpoints()
return (
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_xs,
a.flex_1,
a.py_sm,
a.px_sm,
gtMobile && a.px_md,
a.rounded_sm,
t.atoms.bg_contrast_50,
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
ctx.selected && [
{
backgroundColor:
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
},
],
ctx.disabled && {
opacity: 0.8,
},
]}>
{children}
</View>
)
}

View File

@ -72,7 +72,7 @@ export function Root({children, isInvalid = false}: RootProps) {
return ( return (
<Context.Provider value={context}> <Context.Provider value={context}>
<View <View
style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]} style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]}
{...web({ {...web({
onClick: () => inputRef.current?.focus(), onClick: () => inputRef.current?.focus(),
onMouseOver: onHoverIn, onMouseOver: onHoverIn,

View File

@ -5,6 +5,7 @@ import {HITSLOP_10} from 'lib/constants'
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf' import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
export type ItemState = { export type ItemState = {
name: string name: string
@ -331,15 +332,14 @@ export function createSharedToggleStyles({
export function Checkbox() { export function Checkbox() {
const t = useTheme() const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} = useItemContext() const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
const {baseStyles, baseHoverStyles, indicatorStyles} = const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
createSharedToggleStyles({ theme: t,
theme: t, hovered,
hovered, focused,
focused, selected,
selected, disabled,
disabled, isInvalid,
isInvalid, })
})
return ( return (
<View <View
style={[ style={[
@ -355,21 +355,7 @@ export function Checkbox() {
baseStyles, baseStyles,
hovered || focused ? baseHoverStyles : {}, hovered || focused ? baseHoverStyles : {},
]}> ]}>
{selected ? ( {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
<View
style={[
a.absolute,
a.rounded_2xs,
{height: 12, width: 12},
selected
? {
backgroundColor: t.palette.primary_500,
}
: {},
indicatorStyles,
]}
/>
) : null}
</View> </View>
) )
} }

View File

@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z', path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z',
}) })
export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z',
})

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z',
})

View File

@ -1,5 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE' import {createSinglePathSVG} from './TEMPLATE'
export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M17 16H21.1456C20.8246 11.4468 17.7199 9.48509 15.0001 10.1147M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4ZM18.5 4.5C18.5 5.88071 17.3807 7 16 7C14.6193 7 13.5 5.88071 13.5 4.5C13.5 3.11929 14.6193 2 16 2C17.3807 2 18.5 3.11929 18.5 4.5ZM1 17H13C12.3421 7.66667 1.65792 7.66667 1 17Z', path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z',
}) })

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z',
})

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z',
})

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z',
})

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z',
})

View File

@ -0,0 +1,692 @@
import {describe, it, expect} from '@jest/globals'
import {RichText} from '@atproto/api'
import {hasMutedWord} from '../moderatePost_wrapped'
describe(`hasMutedWord`, () => {
describe(`tags`, () => {
it(`match: outline tag`, () => {
const rt = new RichText({
text: `This is a post #inlineTag`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'outlineTag', targets: ['tag']}],
text: rt.text,
facets: rt.facets,
outlineTags: ['outlineTag'],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: inline tag`, () => {
const rt = new RichText({
text: `This is a post #inlineTag`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
text: rt.text,
facets: rt.facets,
outlineTags: ['outlineTag'],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: content target matches inline tag`, () => {
const rt = new RichText({
text: `This is a post #inlineTag`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'inlineTag', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: ['outlineTag'],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`no match: only tag targets`, () => {
const rt = new RichText({
text: `This is a post`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
})
describe(`early exits`, () => {
it(`match: single character 希`, () => {
/**
* @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c
*/
const rt = new RichText({
text: `改善希望です`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: '希', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`no match: long muted word, short post`, () => {
const rt = new RichText({
text: `hey`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'politics', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
it(`match: exact text`, () => {
const rt = new RichText({
text: `javascript`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'javascript', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`general content`, () => {
it(`match: word within post`, () => {
const rt = new RichText({
text: `This is a post about javascript`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'javascript', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`no match: partial word`, () => {
const rt = new RichText({
text: `Use your brain, Eric`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'ai', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
it(`match: multiline`, () => {
const rt = new RichText({
text: `Use your\n\tbrain, Eric`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'brain', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: :)`, () => {
const rt = new RichText({
text: `So happy :)`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: `:)`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`punctuation semi-fuzzy`, () => {
describe(`yay!`, () => {
const rt = new RichText({
text: `We're federating, yay!`,
})
rt.detectFacetsWithoutResolution()
it(`match: yay!`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'yay!', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: yay`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'yay', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`y!ppee!!`, () => {
const rt = new RichText({
text: `We're federating, y!ppee!!`,
})
rt.detectFacetsWithoutResolution()
it(`match: y!ppee`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'y!ppee', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
// single exclamation point, source has double
it(`no match: y!ppee!`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'y!ppee!', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`Why so S@assy?`, () => {
const rt = new RichText({
text: `Why so S@assy?`,
})
rt.detectFacetsWithoutResolution()
it(`match: S@assy`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'S@assy', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: s@assy`, () => {
const match = hasMutedWord({
mutedWords: [{value: 's@assy', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`New York Times`, () => {
const rt = new RichText({
text: `New York Times`,
})
rt.detectFacetsWithoutResolution()
// case insensitive
it(`match: new york times`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'new york times', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`!command`, () => {
const rt = new RichText({
text: `Idk maybe a bot !command`,
})
rt.detectFacetsWithoutResolution()
it(`match: !command`, () => {
const match = hasMutedWord({
mutedWords: [{value: `!command`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: command`, () => {
const match = hasMutedWord({
mutedWords: [{value: `command`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`no match: !command`, () => {
const rt = new RichText({
text: `Idk maybe a bot command`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: `!command`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
})
describe(`e/acc`, () => {
const rt = new RichText({
text: `I'm e/acc pilled`,
})
rt.detectFacetsWithoutResolution()
it(`match: e/acc`, () => {
const match = hasMutedWord({
mutedWords: [{value: `e/acc`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: acc`, () => {
const match = hasMutedWord({
mutedWords: [{value: `acc`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`super-bad`, () => {
const rt = new RichText({
text: `I'm super-bad`,
})
rt.detectFacetsWithoutResolution()
it(`match: super-bad`, () => {
const match = hasMutedWord({
mutedWords: [{value: `super-bad`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: super`, () => {
const match = hasMutedWord({
mutedWords: [{value: `super`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: super bad`, () => {
const match = hasMutedWord({
mutedWords: [{value: `super bad`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: superbad`, () => {
const match = hasMutedWord({
mutedWords: [{value: `superbad`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
})
describe(`idk_what_this_would_be`, () => {
const rt = new RichText({
text: `Weird post with idk_what_this_would_be`,
})
rt.detectFacetsWithoutResolution()
it(`match: idk what this would be`, () => {
const match = hasMutedWord({
mutedWords: [{value: `idk what this would be`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`no match: idk what this would be for`, () => {
// extra word
const match = hasMutedWord({
mutedWords: [
{value: `idk what this would be for`, targets: ['content']},
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
it(`match: idk`, () => {
// extra word
const match = hasMutedWord({
mutedWords: [{value: `idk`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: idkwhatthiswouldbe`, () => {
const match = hasMutedWord({
mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
})
describe(`parentheses`, () => {
const rt = new RichText({
text: `Post with context(iykyk)`,
})
rt.detectFacetsWithoutResolution()
it(`match: context(iykyk)`, () => {
const match = hasMutedWord({
mutedWords: [{value: `context(iykyk)`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: context`, () => {
const match = hasMutedWord({
mutedWords: [{value: `context`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: iykyk`, () => {
const match = hasMutedWord({
mutedWords: [{value: `iykyk`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: (iykyk)`, () => {
const match = hasMutedWord({
mutedWords: [{value: `(iykyk)`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`🦋`, () => {
const rt = new RichText({
text: `Post with 🦋`,
})
rt.detectFacetsWithoutResolution()
it(`match: 🦋`, () => {
const match = hasMutedWord({
mutedWords: [{value: `🦋`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
})
describe(`phrases`, () => {
describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => {
const rt = new RichText({
text: `I like turtles, or how I learned to stop worrying and love the internet.`,
})
rt.detectFacetsWithoutResolution()
it(`match: stop worrying`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'stop worrying', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: turtles, or how`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'turtles, or how', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
})
describe(`languages without spaces`, () => {
// I love turtles, or how I learned to stop worrying and love the internet
describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => {
const rt = new RichText({
text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`,
})
rt.detectFacetsWithoutResolution()
// internet
it(`match: インターネット`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'インターネット', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
languages: ['ja'],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
})
describe(`doesn't mute own post`, () => {
it(`does mute if it isn't own post`, () => {
const rt = new RichText({
text: `Mute words!`,
})
const match = hasMutedWord({
mutedWords: [{value: 'words', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`doesn't mute own post when muted word is in text`, () => {
const rt = new RichText({
text: `Mute words!`,
})
const match = hasMutedWord({
mutedWords: [{value: 'words', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: true,
})
expect(match).toBe(false)
})
it(`doesn't mute own post when muted word is in tags`, () => {
const rt = new RichText({
text: `Mute #words!`,
})
const match = hasMutedWord({
mutedWords: [{value: 'words', targets: ['tags']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: true,
})
expect(match).toBe(false)
})
})
})

View File

@ -104,18 +104,18 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
// add image embed if present // add image embed if present
if (opts.images?.length) { if (opts.images?.length) {
logger.info(`Uploading images`, { logger.debug(`Uploading images`, {
count: opts.images.length, count: opts.images.length,
}) })
const images: AppBskyEmbedImages.Image[] = [] const images: AppBskyEmbedImages.Image[] = []
for (const image of opts.images) { for (const image of opts.images) {
opts.onStateChange?.(`Uploading image #${images.length + 1}...`) opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
logger.info(`Compressing image`) logger.debug(`Compressing image`)
await image.compress() await image.compress()
const path = image.compressed?.path ?? image.path const path = image.compressed?.path ?? image.path
const {width, height} = image.compressed || image const {width, height} = image.compressed || image
logger.info(`Uploading image`) logger.debug(`Uploading image`)
const res = await uploadBlob(agent, path, 'image/jpeg') const res = await uploadBlob(agent, path, 'image/jpeg')
images.push({ images.push({
image: res.data.blob, image: res.data.blob,

View File

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

View File

@ -0,0 +1,91 @@
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'
import {useCloseAllActiveElements} from 'state/util'
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 closeAllActiveElements = useCloseAllActiveElements()
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
closeAllActiveElements()
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)
},
[hasSession, closeAllActiveElements, openComposer],
)
}

View File

@ -2,19 +2,151 @@ import {
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
moderatePost, moderatePost,
AppBskyActorDefs,
AppBskyFeedPost,
AppBskyRichtextFacet,
AppBskyEmbedImages,
} from '@atproto/api' } from '@atproto/api'
type ModeratePost = typeof moderatePost type ModeratePost = typeof moderatePost
type Options = Parameters<ModeratePost>[1] & { type Options = Parameters<ModeratePost>[1] & {
hiddenPosts?: string[] hiddenPosts?: string[]
mutedWords?: AppBskyActorDefs.MutedWord[]
}
const REGEX = {
LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
SEPARATORS: /[\/\-\\—\(\)\[\]\_]+/g,
WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
}
/**
* List of 2-letter lang codes for languages that either don't use spaces, or
* don't use spaces in a way conducive to word-based filtering.
*
* For these, we use a simple `String.includes` to check for a match.
*/
const LANGUAGE_EXCEPTIONS = [
'ja', // Japanese
'zh', // Chinese
'ko', // Korean
'th', // Thai
'vi', // Vietnamese
]
export function hasMutedWord({
mutedWords,
text,
facets,
outlineTags,
languages,
isOwnPost,
}: {
mutedWords: AppBskyActorDefs.MutedWord[]
text: string
facets?: AppBskyRichtextFacet.Main[]
outlineTags?: string[]
languages?: string[]
isOwnPost: boolean
}) {
if (isOwnPost) return false
const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
const tags = ([] as string[])
.concat(outlineTags || [])
.concat(
facets
?.filter(facet => {
return facet.features.find(feature =>
AppBskyRichtextFacet.isTag(feature),
)
})
.map(t => t.features[0].tag as string) || [],
)
.map(t => t.toLowerCase())
for (const mute of mutedWords) {
const mutedWord = mute.value.toLowerCase()
const postText = text.toLowerCase()
// `content` applies to tags as well
if (tags.includes(mutedWord)) return true
// rest of the checks are for `content` only
if (!mute.targets.includes('content')) continue
// single character or other exception, has to use includes
if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord))
return true
// too long
if (mutedWord.length > postText.length) continue
// exact match
if (mutedWord === postText) return true
// any muted phrase with space or punctuation
if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
return true
// check individual character groups
const words = postText.split(REGEX.WORD_BOUNDARY)
for (const word of words) {
if (word === mutedWord) return true
// compare word without leading/trailing punctuation, but allow internal
// punctuation (such as `s@ssy`)
const wordTrimmedPunctuation = word.replace(
REGEX.LEADING_TRAILING_PUNCTUATION,
'',
)
if (mutedWord === wordTrimmedPunctuation) return true
if (mutedWord.length > wordTrimmedPunctuation.length) continue
// handle hyphenated, slash separated words, etc
if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
// check against full normalized phrase
const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
REGEX.SEPARATORS,
' ',
)
const mutedWordNormalizedSeparators = mutedWord.replace(
REGEX.SEPARATORS,
' ',
)
// hyphenated (or other sep) to spaced words
if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
return true
/* Disabled for now e.g. `super-cool` to `supercool`
const wordNormalizedCompressed = wordNormalizedSeparators.replace(
REGEX.WORD_BOUNDARY,
'',
)
const mutedWordNormalizedCompressed =
mutedWordNormalizedSeparators.replace(/\s+?/g, '')
// hyphenated (or other sep) to non-hyphenated contiguous word
if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
return true
*/
// then individual parts of separated phrases/words
const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
for (const wp of wordParts) {
// still retain internal punctuation
if (wp === mutedWord) return true
}
}
}
}
return false
} }
export function moderatePost_wrapped( export function moderatePost_wrapped(
subject: Parameters<ModeratePost>[0], subject: Parameters<ModeratePost>[0],
opts: Options, opts: Options,
) { ) {
const {hiddenPosts = [], ...options} = opts const {hiddenPosts = [], mutedWords = [], ...options} = opts
const moderations = moderatePost(subject, options) const moderations = moderatePost(subject, options)
const isOwnPost = subject.author.did === opts.userDid
if (hiddenPosts.includes(subject.uri)) { if (hiddenPosts.includes(subject.uri)) {
moderations.content.filter = true moderations.content.filter = true
@ -29,15 +161,86 @@ export function moderatePost_wrapped(
} }
} }
if (AppBskyFeedPost.isRecord(subject.record)) {
let muted = hasMutedWord({
mutedWords,
text: subject.record.text,
facets: subject.record.facets || [],
outlineTags: subject.record.tags || [],
languages: subject.record.langs,
isOwnPost,
})
if (
subject.record.embed &&
AppBskyEmbedImages.isMain(subject.record.embed)
) {
for (const image of subject.record.embed.images) {
muted =
muted ||
hasMutedWord({
mutedWords,
text: image.alt,
facets: [],
outlineTags: [],
languages: subject.record.langs,
isOwnPost,
})
}
}
if (muted) {
moderations.content.filter = true
moderations.content.blur = true
if (!moderations.content.cause) {
moderations.content.cause = {
// @ts-ignore Temporary extension to the moderation system -prf
type: 'muted-word',
source: {type: 'user'},
priority: 1,
}
}
}
}
if (subject.embed) { if (subject.embed) {
let embedHidden = false let embedHidden = false
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
embedHidden = hiddenPosts.includes(subject.embed.record.uri) embedHidden = hiddenPosts.includes(subject.embed.record.uri)
if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
embedHidden =
embedHidden ||
hasMutedWord({
mutedWords,
text: subject.embed.record.value.text,
facets: subject.embed.record.value.facets,
outlineTags: subject.embed.record.value.tags,
languages: subject.embed.record.value.langs,
isOwnPost,
})
if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) {
for (const image of subject.embed.record.value.embed.images) {
embedHidden =
embedHidden ||
hasMutedWord({
mutedWords,
text: image.alt,
facets: [],
outlineTags: [],
languages: subject.embed.record.value.langs,
isOwnPost,
})
}
}
}
} }
if ( if (
AppBskyEmbedRecordWithMedia.isView(subject.embed) && AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
) { ) {
// TODO what
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri) embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
} }
if (embedHidden) { if (embedHidden) {

View File

@ -67,6 +67,13 @@ export function describeModerationCause(
description: 'You have hidden this post', description: 'You have hidden this post',
} }
} }
// @ts-ignore Temporary extension to the moderation system -prf
if (cause.type === 'muted-word') {
return {
name: 'Post hidden by muted word',
description: `You've chosen to hide a word or tag within this post.`,
}
}
return cause.labelDef.strings[context].en return cause.labelDef.strings[context].en
} }

View File

@ -25,3 +25,13 @@ export function makeCustomFeedLink(
export function makeListLink(did: string, rkey: string, ...segments: string[]) { export function makeListLink(did: string, rkey: string, ...segments: string[]) {
return [`/profile`, did, 'lists', rkey, ...segments].join('/') return [`/profile`, did, 'lists', rkey, ...segments].join('/')
} }
export function makeTagLink(did: string) {
return `/search?q=${encodeURIComponent(did)}`
}
export function makeSearchLink(props: {query: string; from?: 'me' | string}) {
return `/search?q=${encodeURIComponent(
props.query + (props.from ? ` from:${props.from}` : ''),
)}`
}

View File

@ -33,6 +33,7 @@ export type CommonNavigatorParams = {
PreferencesFollowingFeed: undefined PreferencesFollowingFeed: undefined
PreferencesThreads: undefined PreferencesThreads: undefined
PreferencesExternalEmbeds: undefined PreferencesExternalEmbeds: undefined
Search: {q?: string}
} }
export type BottomTabNavigatorParams = CommonNavigatorParams & { export type BottomTabNavigatorParams = CommonNavigatorParams & {

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -114,7 +114,7 @@ export function AdultContentEnabledPref({
</Trans> </Trans>
</Prompt.Description> </Prompt.Description>
<Prompt.Actions> <Prompt.Actions>
<Prompt.Action onPress={prompt.close}>OK</Prompt.Action> <Prompt.Action onPress={() => prompt.close()}>OK</Prompt.Action>
</Prompt.Actions> </Prompt.Actions>
</Prompt.Outer> </Prompt.Outer>
</> </>

View File

@ -232,7 +232,7 @@ export function reducer(
}) })
if (s.activeStep !== state.activeStep) { if (s.activeStep !== state.activeStep) {
logger.info(`onboarding: step changed`, {activeStep: state.activeStep}) logger.debug(`onboarding: step changed`, {activeStep: state.activeStep})
} }
return state return state

View File

@ -1,20 +1,32 @@
import React from 'react' import React from 'react'
import {DialogControlProps} from '#/components/Dialog' import {DialogControlRefProps} from '#/components/Dialog'
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
const DialogContext = React.createContext<{ const DialogContext = React.createContext<{
/**
* The currently active `useDialogControl` hooks.
*/
activeDialogs: React.MutableRefObject< activeDialogs: React.MutableRefObject<
Map<string, React.MutableRefObject<DialogControlProps>> Map<string, React.MutableRefObject<DialogControlRefProps>>
> >
/**
* The currently open dialogs, referenced by their IDs, generated from
* `useId`.
*/
openDialogs: React.MutableRefObject<Set<string>>
}>({ }>({
activeDialogs: { activeDialogs: {
current: new Map(), current: new Map(),
}, },
openDialogs: {
current: new Set(),
},
}) })
const DialogControlContext = React.createContext<{ const DialogControlContext = React.createContext<{
closeAllDialogs(): void closeAllDialogs(): boolean
}>({ }>({
closeAllDialogs: () => {}, closeAllDialogs: () => false,
}) })
export function useDialogStateContext() { export function useDialogStateContext() {
@ -27,17 +39,22 @@ export function useDialogStateControlContext() {
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
const activeDialogs = React.useRef< const activeDialogs = React.useRef<
Map<string, React.MutableRefObject<DialogControlProps>> Map<string, React.MutableRefObject<DialogControlRefProps>>
>(new Map()) >(new Map())
const openDialogs = React.useRef<Set<string>>(new Set())
const closeAllDialogs = React.useCallback(() => { const closeAllDialogs = React.useCallback(() => {
activeDialogs.current.forEach(dialog => dialog.current.close()) activeDialogs.current.forEach(dialog => dialog.current.close())
return openDialogs.current.size > 0
}, []) }, [])
const context = React.useMemo(() => ({activeDialogs}), [])
const context = React.useMemo(() => ({activeDialogs, openDialogs}), [])
const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs]) const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs])
return ( return (
<DialogContext.Provider value={context}> <DialogContext.Provider value={context}>
<DialogControlContext.Provider value={controls}> <DialogControlContext.Provider value={controls}>
{children} <GlobalDialogsProvider>{children}</GlobalDialogsProvider>
</DialogControlContext.Provider> </DialogControlContext.Provider>
</DialogContext.Provider> </DialogContext.Provider>
) )

View File

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

View File

@ -26,7 +26,7 @@ test('migrate: fresh install', async () => {
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(read).toHaveBeenCalledTimes(1) expect(read).toHaveBeenCalledTimes(1)
expect(logger.info).toHaveBeenCalledWith( expect(logger.debug).toHaveBeenCalledWith(
'persisted state: no migration needed', 'persisted state: no migration needed',
) )
}) })
@ -38,7 +38,7 @@ test('migrate: fresh install, existing new storage', async () => {
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(read).toHaveBeenCalledTimes(1) expect(read).toHaveBeenCalledTimes(1)
expect(logger.info).toHaveBeenCalledWith( expect(logger.debug).toHaveBeenCalledWith(
'persisted state: no migration needed', 'persisted state: no migration needed',
) )
}) })
@ -68,7 +68,7 @@ test('migrate: has legacy data', async () => {
await migrate() await migrate()
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP)) expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
expect(logger.info).toHaveBeenCalledWith( expect(logger.debug).toHaveBeenCalledWith(
'persisted state: migrated legacy storage', 'persisted state: migrated legacy storage',
) )
}) })

View File

@ -19,7 +19,7 @@ const _emitter = new EventEmitter()
* the Provider. * the Provider.
*/ */
export async function init() { export async function init() {
logger.info('persisted state: initializing') logger.debug('persisted state: initializing')
broadcast.onmessage = onBroadcastMessage broadcast.onmessage = onBroadcastMessage
@ -27,11 +27,11 @@ export async function init() {
await migrate() // migrate old store await migrate() // migrate old store
const stored = await store.read() // check for new store const stored = await store.read() // check for new store
if (!stored) { if (!stored) {
logger.info('persisted state: initializing default storage') logger.debug('persisted state: initializing default storage')
await store.write(defaults) // opt: init new store await store.write(defaults) // opt: init new store
} }
_state = stored || defaults // return new store _state = stored || defaults // return new store
logger.log('persisted state: initialized') logger.debug('persisted state: initialized')
} catch (e) { } catch (e) {
logger.error('persisted state: failed to load root state from storage', { logger.error('persisted state: failed to load root state from storage', {
message: e, message: e,

View File

@ -121,7 +121,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
* local storage AND old storage exists. * local storage AND old storage exists.
*/ */
export async function migrate() { export async function migrate() {
logger.info('persisted state: check need to migrate') logger.debug('persisted state: check need to migrate')
try { try {
const rawLegacyData = await AsyncStorage.getItem( const rawLegacyData = await AsyncStorage.getItem(
@ -131,7 +131,7 @@ export async function migrate() {
const alreadyMigrated = Boolean(newData) const alreadyMigrated = Boolean(newData)
if (!alreadyMigrated && rawLegacyData) { if (!alreadyMigrated && rawLegacyData) {
logger.info('persisted state: migrating legacy storage') logger.debug('persisted state: migrating legacy storage')
const legacyData = JSON.parse(rawLegacyData) const legacyData = JSON.parse(rawLegacyData)
const newData = transform(legacyData) const newData = transform(legacyData)
@ -139,14 +139,14 @@ export async function migrate() {
if (validate.success) { if (validate.success) {
await write(newData) await write(newData)
logger.info('persisted state: migrated legacy storage') logger.debug('persisted state: migrated legacy storage')
} else { } else {
logger.error('persisted state: legacy data failed validation', { logger.error('persisted state: legacy data failed validation', {
message: validate.error, message: validate.error,
}) })
} }
} else { } else {
logger.info('persisted state: no migration needed') logger.debug('persisted state: no migration needed')
} }
} catch (e: any) { } catch (e: any) {
logger.error(e, { logger.error(e, {

View File

@ -1,11 +1,9 @@
import React from 'react'
import { import {
useQuery, useQuery,
useInfiniteQuery, useInfiniteQuery,
InfiniteData, InfiniteData,
QueryKey, QueryKey,
useMutation, useMutation,
useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { import {
AtUri, AtUri,
@ -15,7 +13,6 @@ import {
AppBskyUnspeccedGetPopularFeedGenerators, AppBskyUnspeccedGetPopularFeedGenerators,
} from '@atproto/api' } from '@atproto/api'
import {logger} from '#/logger'
import {router} from '#/routes' import {router} from '#/routes'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
@ -219,83 +216,59 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = {
likeUri: '', likeUri: '',
} }
export function usePinnedFeedsInfos(): { export function usePinnedFeedsInfos() {
feeds: FeedSourceInfo[] const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
hasPinnedCustom: boolean const pinnedUris = preferences?.feeds?.pinned ?? []
isLoading: boolean
} {
const queryClient = useQueryClient()
const [tabs, setTabs] = React.useState<FeedSourceInfo[]>([
FOLLOWING_FEED_STUB,
])
const [isLoading, setLoading] = React.useState(true)
const {data: preferences} = usePreferencesQuery()
const hasPinnedCustom = React.useMemo<boolean>(() => { return useQuery({
return tabs.some(tab => tab !== FOLLOWING_FEED_STUB) staleTime: STALE.INFINITY,
}, [tabs]) enabled: !isLoadingPrefs,
queryKey: ['pinnedFeedsInfos', pinnedUris.join(',')],
queryFn: async () => {
let resolved = new Map()
React.useEffect(() => { // Get all feeds. We can do this in a batch.
if (!preferences?.feeds?.pinned) return const feedUris = pinnedUris.filter(
const uris = preferences.feeds.pinned uri => getFeedTypeFromUri(uri) === 'feed',
)
async function fetchFeedInfo() { let feedsPromise = Promise.resolve()
const reqs = [] if (feedUris.length > 0) {
feedsPromise = getAgent()
for (const uri of uris) { .app.bsky.feed.getFeedGenerators({
const cached = queryClient.getQueryData<FeedSourceInfo>( feeds: feedUris,
feedSourceInfoQueryKey({uri}), })
) .then(res => {
for (let feedView of res.data.feeds) {
if (cached) { resolved.set(feedView.uri, hydrateFeedGenerator(feedView))
reqs.push(cached) }
} else { })
reqs.push(
(async () => {
// these requests can fail, need to filter those out
try {
return await queryClient.fetchQuery({
staleTime: STALE.SECONDS.FIFTEEN,
queryKey: feedSourceInfoQueryKey({uri}),
queryFn: async () => {
const type = getFeedTypeFromUri(uri)
if (type === 'feed') {
const res =
await getAgent().app.bsky.feed.getFeedGenerator({
feed: uri,
})
return hydrateFeedGenerator(res.data.view)
} else {
const res = await getAgent().app.bsky.graph.getList({
list: uri,
limit: 1,
})
return hydrateList(res.data.list)
}
},
})
} catch (e) {
// expected failure
logger.info(`usePinnedFeedsInfos: failed to fetch ${uri}`, {
error: e,
})
}
})(),
)
}
} }
const views = (await Promise.all(reqs)).filter( // Get all lists. This currently has to be done individually.
Boolean, const listUris = pinnedUris.filter(
) as FeedSourceInfo[] uri => getFeedTypeFromUri(uri) === 'list',
)
const listsPromises = listUris.map(listUri =>
getAgent()
.app.bsky.graph.getList({
list: listUri,
limit: 1,
})
.then(res => {
const listView = res.data.list
resolved.set(listView.uri, hydrateList(listView))
}),
)
setTabs([FOLLOWING_FEED_STUB].concat(views)) // The returned result will have the original order.
setLoading(false) const result = [FOLLOWING_FEED_STUB]
} await Promise.allSettled([feedsPromise, ...listsPromises])
for (let pinnedUri of pinnedUris) {
fetchFeedInfo() if (resolved.has(pinnedUri)) {
}, [queryClient, setTabs, preferences?.feeds?.pinned]) result.push(resolved.get(pinnedUri))
}
return {feeds: tabs, hasPinnedCustom, isLoading} }
return result
},
})
} }

View File

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

View File

@ -49,4 +49,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS, threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
userAge: 13, // TODO(pwi) userAge: 13, // TODO(pwi)
interests: {tags: []}, interests: {tags: []},
mutedWords: [],
hiddenPosts: [],
} }

View File

@ -1,6 +1,10 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {LabelPreference, BskyFeedViewPreference} from '@atproto/api' import {
LabelPreference,
BskyFeedViewPreference,
AppBskyActorDefs,
} from '@atproto/api'
import {track} from '#/lib/analytics/analytics' import {track} from '#/lib/analytics/analytics'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
@ -108,6 +112,7 @@ export function useModerationOpts() {
return { return {
...moderationOpts, ...moderationOpts,
hiddenPosts, hiddenPosts,
mutedWords: prefs.data.mutedWords || [],
} }
}, [currentAccount?.did, prefs.data, hiddenPosts]) }, [currentAccount?.did, prefs.data, hiddenPosts])
return opts return opts
@ -278,3 +283,45 @@ export function useUnpinFeedMutation() {
}, },
}) })
} }
export function useUpsertMutedWordsMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
await getAgent().upsertMutedWords(mutedWords)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
export function useUpdateMutedWordMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
await getAgent().updateMutedWord(mutedWord)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
export function useRemoveMutedWordMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
await getAgent().removeMutedWord(mutedWord)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}

View File

@ -133,7 +133,7 @@ function createPersistSessionHandler(
accessJwt: session?.accessJwt, accessJwt: session?.accessJwt,
} }
logger.info(`session: persistSession`, { logger.debug(`session: persistSession`, {
event, event,
deactivated: refreshedAccount.deactivated, deactivated: refreshedAccount.deactivated,
}) })
@ -320,7 +320,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) )
const logout = React.useCallback<ApiContext['logout']>(async () => { const logout = React.useCallback<ApiContext['logout']>(async () => {
logger.info(`session: logout`) logger.debug(`session: logout`)
clearCurrentAccount() clearCurrentAccount()
setStateAndPersist(s => { setStateAndPersist(s => {
return { return {
@ -374,7 +374,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
if (canReusePrevSession) { if (canReusePrevSession) {
logger.info(`session: attempting to reuse previous session`) logger.debug(`session: attempting to reuse previous session`)
agent.session = prevSession agent.session = prevSession
__globalAgent = agent __globalAgent = agent
@ -384,7 +384,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
if (prevSession.deactivated) { if (prevSession.deactivated) {
// don't attempt to resume // don't attempt to resume
// use will be taken to the deactivated screen // use will be taken to the deactivated screen
logger.info(`session: reusing session for deactivated account`) logger.debug(`session: reusing session for deactivated account`)
return return
} }
@ -410,7 +410,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
__globalAgent = PUBLIC_BSKY_AGENT __globalAgent = PUBLIC_BSKY_AGENT
}) })
} else { } else {
logger.info(`session: attempting to resume using previous session`) logger.debug(`session: attempting to resume using previous session`)
try { try {
const freshAccount = await resumeSessionWithFreshAccount() const freshAccount = await resumeSessionWithFreshAccount()
@ -431,7 +431,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
async function resumeSessionWithFreshAccount(): Promise<SessionAccount> { async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
logger.info(`session: resumeSessionWithFreshAccount`) logger.debug(`session: resumeSessionWithFreshAccount`)
await networkRetry(1, () => agent.resumeSession(prevSession)) await networkRetry(1, () => agent.resumeSession(prevSession))
@ -552,11 +552,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return persisted.onUpdate(() => { return persisted.onUpdate(() => {
const session = persisted.get('session') const session = persisted.get('session')
logger.info(`session: persisted onUpdate`, {}) logger.debug(`session: persisted onUpdate`, {})
if (session.currentAccount && session.currentAccount.refreshJwt) { if (session.currentAccount && session.currentAccount.refreshJwt) {
if (session.currentAccount?.did !== state.currentAccount?.did) { if (session.currentAccount?.did !== state.currentAccount?.did) {
logger.info(`session: persisted onUpdate, switching accounts`, { logger.debug(`session: persisted onUpdate, switching accounts`, {
from: { from: {
did: state.currentAccount?.did, did: state.currentAccount?.did,
handle: state.currentAccount?.handle, handle: state.currentAccount?.handle,
@ -569,7 +569,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
initSession(session.currentAccount) initSession(session.currentAccount)
} else { } else {
logger.info(`session: persisted onUpdate, updating session`, {}) logger.debug(`session: persisted onUpdate, updating session`, {})
/* /*
* Use updated session in this tab's agent. Do not call * Use updated session in this tab's agent. Do not call

View File

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

View File

@ -3,6 +3,7 @@ import {useLightboxControls} from './lightbox'
import {useModalControls} from './modals' import {useModalControls} from './modals'
import {useComposerControls} from './shell/composer' import {useComposerControls} from './shell/composer'
import {useSetDrawerOpen} from './shell/drawer-open' import {useSetDrawerOpen} from './shell/drawer-open'
import {useDialogStateControlContext} from '#/state/dialogs'
/** /**
* returns true if something was closed * returns true if something was closed
@ -12,6 +13,7 @@ export function useCloseAnyActiveElement() {
const {closeLightbox} = useLightboxControls() const {closeLightbox} = useLightboxControls()
const {closeModal} = useModalControls() const {closeModal} = useModalControls()
const {closeComposer} = useComposerControls() const {closeComposer} = useComposerControls()
const {closeAllDialogs} = useDialogStateControlContext()
const setDrawerOpen = useSetDrawerOpen() const setDrawerOpen = useSetDrawerOpen()
return useCallback(() => { return useCallback(() => {
if (closeLightbox()) { if (closeLightbox()) {
@ -23,9 +25,12 @@ export function useCloseAnyActiveElement() {
if (closeComposer()) { if (closeComposer()) {
return true return true
} }
if (closeAllDialogs()) {
return true
}
setDrawerOpen(false) setDrawerOpen(false)
return false return false
}, [closeLightbox, closeModal, closeComposer, setDrawerOpen]) }, [closeLightbox, closeModal, closeComposer, setDrawerOpen, closeAllDialogs])
} }
/** /**
@ -35,11 +40,19 @@ export function useCloseAllActiveElements() {
const {closeLightbox} = useLightboxControls() const {closeLightbox} = useLightboxControls()
const {closeAllModals} = useModalControls() const {closeAllModals} = useModalControls()
const {closeComposer} = useComposerControls() const {closeComposer} = useComposerControls()
const {closeAllDialogs: closeAlfDialogs} = useDialogStateControlContext()
const setDrawerOpen = useSetDrawerOpen() const setDrawerOpen = useSetDrawerOpen()
return useCallback(() => { return useCallback(() => {
closeLightbox() closeLightbox()
closeAllModals() closeAllModals()
closeComposer() closeComposer()
closeAlfDialogs()
setDrawerOpen(false) setDrawerOpen(false)
}, [closeLightbox, closeAllModals, closeComposer, setDrawerOpen]) }, [
closeLightbox,
closeAllModals,
closeComposer,
closeAlfDialogs,
setDrawerOpen,
])
} }

View File

@ -133,8 +133,8 @@ function IsValidIcon({valid}: {valid: boolean}) {
const t = useTheme() const t = useTheme()
if (!valid) { if (!valid) {
return <Check size="md" style={{color: t.palette.negative_500}} /> return <Times size="md" style={{color: t.palette.negative_500}} />
} }
return <Times size="md" style={{color: t.palette.positive_700}} /> return <Check size="md" style={{color: t.palette.positive_700}} />
} }

View File

@ -107,7 +107,7 @@ export const LoginForm = ({
const errMsg = e.toString() const errMsg = e.toString()
setIsProcessing(false) setIsProcessing(false)
if (errMsg.includes('Authentication Required')) { if (errMsg.includes('Authentication Required')) {
logger.info('Failed to login due to invalid credentials', { logger.debug('Failed to login due to invalid credentials', {
error: errMsg, error: errMsg,
}) })
setError(_(msg`Invalid username or password`)) setError(_(msg`Invalid username or password`))

View File

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

Some files were not shown because too many files have changed in this diff Show More