Merge remote-tracking branch 'origin/main' into follow-is-as-follow-does

* origin/main: (45 commits)
  Added trans (#4890)
  Keep interstitial fresh on refresh (#4888)
  Include popcluster in suggestion ranking (#4887)
  Add logging of selected feed preference when displaying the following feed (#4789)
  [Video] Visibility detection view (#4741)
  [Videos] Video player - PR #2 - better web support (#4732)
  [Video] Authed video upload (#4885)
  Tweak feed manip to show cases of A -> B without further children (#4883)
  Show own replies before follows' replies in threads (#4882)
  Show more replies in Following (different heuristic) (#4880)
  [Persisted] Make broadcast subscriptions granular by key (#4874)
  [Persisted] Fix the race condition causing clobbered writes between tabs (#4873)
  [Persisted] Fork web and native, make it synchronous on the web (#4872)
  Show replies in context of their threads (#4871)
  Add `PlatformInfo` module (#4877)
  bskyweb: optional basic auth password middleware (#4759)
  tweak list header (#4870)
  Respect labels on feeds and lists (#4818)
  Only show replies in Following if following all involved actors (#4869)
  Always limit Following replies to the people you follow (#4868)
  ...
This commit is contained in:
Eric Bailey
2024-08-08 08:45:37 -05:00
198 changed files with 4857 additions and 2076 deletions
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 392 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 369 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 384 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 440 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 443 B

@@ -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="M12.097 2.53a1 1 0 0 1-.041 1.07 6 6 0 0 0 8.345 8.344 1 1 0 0 1 1.563.908c-.434 5.122-4.728 9.144-9.962 9.144-5.522 0-9.998-4.476-9.998-9.998 0-5.234 4.021-9.528 9.144-9.962a1 1 0 0 1 .949.494ZM9.424 4.424a7.998 7.998 0 1 0 10.152 10.152A8 8 0 0 1 9.424 4.424Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 403 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 247 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 183 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 315 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 326 B

@@ -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 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 184 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z"/></svg>

After

Width:  |  Height:  |  Size: 239 B

@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 276 B

@@ -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 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 374 B

+10 -3
View File
@@ -41,10 +41,10 @@ func run(args []string) {
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
},
&cli.StringFlag{
Name: "ogcard-host",
Usage: "scheme, hostname, and port of ogcard service",
Name: "ogcard-host",
Usage: "scheme, hostname, and port of ogcard service",
Required: false,
EnvVars: []string{"OGCARD_HOST"},
EnvVars: []string{"OGCARD_HOST"},
},
&cli.StringFlag{
Name: "http-address",
@@ -67,6 +67,13 @@ func run(args []string) {
Required: false,
EnvVars: []string{"DEBUG"},
},
&cli.StringFlag{
Name: "basic-auth-password",
Usage: "optional password to restrict access to web interface",
Required: false,
Value: "",
EnvVars: []string{"BASIC_AUTH_PASSWORD"},
},
},
},
}
+15
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"io/fs"
@@ -48,6 +49,7 @@ func serve(cctx *cli.Context) error {
appviewHost := cctx.String("appview-host")
ogcardHost := cctx.String("ogcard-host")
linkHost := cctx.String("link-host")
basicAuthPassword := cctx.String("basic-auth-password")
// Echo
e := echo.New()
@@ -140,6 +142,18 @@ func serve(cctx *cli.Context) error {
},
}))
// optional password gating of entire web interface
if basicAuthPassword != "" {
e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
// Be careful to use constant time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(username), []byte("admin")) == 1 &&
subtle.ConstantTimeCompare([]byte(password), []byte(basicAuthPassword)) == 1 {
return true, nil
}
return false, nil
}))
}
// redirect trailing slash to non-trailing slash.
// all of our current endpoints have no trailing slash.
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
@@ -211,6 +225,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric)
e.GET("/settings/accessibility", server.WebGeneric)
e.GET("/settings/appearance", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
+13
View File
@@ -95,3 +95,16 @@ jest.mock('expo-application', () => ({
nativeApplicationVersion: '1.0.0',
nativeBuildVersion: '1',
}))
jest.mock('expo-modules-core', () => ({
requireNativeModule: jest.fn().mockImplementation(moduleName => {
if (moduleName === 'ExpoPlatformInfo') {
return {
getIsReducedMotionEnabled: () => false,
}
}
}),
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
return () => null
}),
}))
@@ -0,0 +1,24 @@
package expo.modules.blueskyswissarmy.platforminfo
import android.provider.Settings
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoPlatformInfoModule : Module() {
override fun definition() =
ModuleDefinition {
Name("ExpoPlatformInfo")
// See https://github.com/software-mansion/react-native-reanimated/blob/7df5fd57d608fe25724608835461cd925ff5151d/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeProxy/NativeProxyCommon.java#L242
Function("getIsReducedMotionEnabled") {
val resolver = appContext.reactContext?.contentResolver ?: return@Function false
val scale = Settings.Global.getString(resolver, Settings.Global.TRANSITION_ANIMATION_SCALE) ?: return@Function false
try {
return@Function scale.toFloat() == 0f
} catch (_: Error) {
return@Function false
}
}
}
}
@@ -0,0 +1,23 @@
package expo.modules.blueskyswissarmy.visibilityview
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoBlueskyVisibilityViewModule : Module() {
override fun definition() =
ModuleDefinition {
Name("ExpoBlueskyVisibilityView")
AsyncFunction("updateActiveViewAsync") {
VisibilityViewManager.updateActiveView()
}
View(VisibilityView::class) {
Events(arrayOf("onChangeStatus"))
Prop("enabled") { view: VisibilityView, prop: Boolean ->
view.isViewEnabled = prop
}
}
}
}
@@ -0,0 +1,63 @@
package expo.modules.blueskyswissarmy.visibilityview
import android.content.Context
import android.graphics.Rect
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
class VisibilityView(
context: Context,
appContext: AppContext,
) : ExpoView(context, appContext) {
var isViewEnabled: Boolean = false
private val onChangeStatus by EventDispatcher()
private var isCurrentlyActive = false
override fun onAttachedToWindow() {
super.onAttachedToWindow()
VisibilityViewManager.addView(this)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
VisibilityViewManager.removeView(this)
}
fun setIsCurrentlyActive(isActive: Boolean) {
if (isCurrentlyActive == isActive) {
return
}
this.isCurrentlyActive = isActive
this.onChangeStatus(
mapOf(
"isActive" to isActive,
),
)
}
fun getPositionOnScreen(): Rect? {
if (!this.isShown) {
return null
}
val screenPosition = intArrayOf(0, 0)
this.getLocationInWindow(screenPosition)
return Rect(
screenPosition[0],
screenPosition[1],
screenPosition[0] + this.width,
screenPosition[1] + this.height,
)
}
fun isViewableEnough(): Boolean {
val positionOnScreen = this.getPositionOnScreen() ?: return false
val visibleArea = positionOnScreen.width() * positionOnScreen.height()
val totalArea = this.width * this.height
return visibleArea >= 0.5 * totalArea
}
}
@@ -0,0 +1,82 @@
package expo.modules.blueskyswissarmy.visibilityview
import android.graphics.Rect
class VisibilityViewManager {
companion object {
private val views = HashMap<Int, VisibilityView>()
private var currentlyActiveView: VisibilityView? = null
private var prevCount = 0
fun addView(view: VisibilityView) {
this.views[view.id] = view
if (this.prevCount == 0) {
this.updateActiveView()
}
this.prevCount = this.views.count()
}
fun removeView(view: VisibilityView) {
this.views.remove(view.id)
this.prevCount = this.views.count()
}
fun updateActiveView() {
var activeView: VisibilityView? = null
val count = this.views.count()
if (count == 1) {
val view = this.views.values.first()
if (view.isViewableEnough()) {
activeView = view
}
} else if (count > 1) {
val views = this.views.values
var mostVisibleView: VisibilityView? = null
var mostVisiblePosition: Rect? = null
views.forEach { view ->
if (!view.isViewableEnough()) {
return
}
val position = view.getPositionOnScreen() ?: return@forEach
val topY = position.centerY() - (position.height() / 2)
if (topY >= 150) {
if (mostVisiblePosition == null) {
mostVisiblePosition = position
}
if (position.centerY() <= mostVisiblePosition!!.centerY()) {
mostVisibleView = view
mostVisiblePosition = position
}
}
}
activeView = mostVisibleView
}
if (activeView == this.currentlyActiveView) {
return
}
this.clearActiveView()
if (activeView != null) {
this.setActiveView(activeView)
}
}
private fun clearActiveView() {
this.currentlyActiveView?.setIsCurrentlyActive(false)
this.currentlyActiveView = null
}
private fun setActiveView(view: VisibilityView) {
view.setIsCurrentlyActive(true)
this.currentlyActiveView = view
}
}
}
@@ -1,12 +1,19 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"]
"modules": [
"ExpoBlueskySharedPrefsModule",
"ExpoBlueskyReferrerModule",
"ExpoBlueskyVisibilityViewModule",
"ExpoPlatformInfoModule"
]
},
"android": {
"modules": [
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule"
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
"expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule",
"expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule"
]
}
}
+3 -1
View File
@@ -1,4 +1,6 @@
import * as PlatformInfo from './src/PlatformInfo'
import * as Referrer from './src/Referrer'
import * as SharedPrefs from './src/SharedPrefs'
import VisibilityView from './src/VisibilityView'
export {Referrer, SharedPrefs}
export {PlatformInfo, Referrer, SharedPrefs, VisibilityView}
@@ -0,0 +1,11 @@
import ExpoModulesCore
public class ExpoPlatformInfoModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoPlatformInfo")
Function("getIsReducedMotionEnabled") {
return UIAccessibility.isReduceMotionEnabled
}
}
}
@@ -0,0 +1,21 @@
import ExpoModulesCore
public class ExpoBlueskyVisibilityViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoBlueskyVisibilityView")
AsyncFunction("updateActiveViewAsync") {
VisibilityViewManager.shared.updateActiveView()
}
View(VisibilityView.self) {
Events([
"onChangeStatus"
])
Prop("enabled") { (view: VisibilityView, prop: Bool) in
view.enabled = prop
}
}
}
}
@@ -0,0 +1,86 @@
import Foundation
class VisibilityViewManager {
static let shared = VisibilityViewManager()
private let views = NSHashTable<VisibilityView>(options: .weakMemory)
private var currentlyActiveView: VisibilityView?
private var screenHeight: CGFloat = UIScreen.main.bounds.height
private var prevCount = 0
func addView(_ view: VisibilityView) {
self.views.add(view)
if self.prevCount == 0 {
self.updateActiveView()
}
self.prevCount = self.views.count
}
func removeView(_ view: VisibilityView) {
self.views.remove(view)
self.prevCount = self.views.count
}
func updateActiveView() {
DispatchQueue.main.async {
var activeView: VisibilityView?
if self.views.count == 1 {
let view = self.views.allObjects[0]
if view.isViewableEnough() {
activeView = view
}
} else if self.views.count > 1 {
let views = self.views.allObjects
var mostVisibleView: VisibilityView?
var mostVisiblePosition: CGRect?
views.forEach { view in
if !view.isViewableEnough() {
return
}
guard let position = view.getPositionOnScreen() else {
return
}
if position.minY >= 150 {
if mostVisiblePosition == nil {
mostVisiblePosition = position
}
if let unwrapped = mostVisiblePosition,
position.minY <= unwrapped.minY {
mostVisibleView = view
mostVisiblePosition = position
}
}
}
activeView = mostVisibleView
}
if activeView == self.currentlyActiveView {
return
}
self.clearActiveView()
if let view = activeView {
self.setActiveView(view)
}
}
}
private func clearActiveView() {
if let currentlyActiveView = self.currentlyActiveView {
currentlyActiveView.setIsCurrentlyActive(isActive: false)
self.currentlyActiveView = nil
}
}
private func setActiveView(_ view: VisibilityView) {
view.setIsCurrentlyActive(isActive: true)
self.currentlyActiveView = view
}
}
@@ -0,0 +1,69 @@
import ExpoModulesCore
class VisibilityView: ExpoView {
var enabled = false {
didSet {
if enabled {
VisibilityViewManager.shared.removeView(self)
}
}
}
private let onChangeStatus = EventDispatcher()
private var isCurrentlyActiveView = false
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
}
public override func willMove(toWindow newWindow: UIWindow?) {
super.willMove(toWindow: newWindow)
if !self.enabled {
return
}
if newWindow == nil {
VisibilityViewManager.shared.removeView(self)
} else {
VisibilityViewManager.shared.addView(self)
}
}
func setIsCurrentlyActive(isActive: Bool) {
if isCurrentlyActiveView == isActive {
return
}
self.isCurrentlyActiveView = isActive
self.onChangeStatus([
"isActive": isActive
])
}
}
// 🚨 DANGER 🚨
// These functions need to be called from the main thread. Xcode will warn you if you call one of them
// off the main thread, so pay attention!
extension UIView {
func getPositionOnScreen() -> CGRect? {
if let window = self.window {
return self.convert(self.bounds, to: window)
}
return nil
}
func isViewableEnough() -> Bool {
guard let window = self.window else {
return false
}
let viewFrameOnScreen = self.convert(self.bounds, to: window)
let screenBounds = window.bounds
let intersection = viewFrameOnScreen.intersection(screenBounds)
let viewHeight = viewFrameOnScreen.height
let intersectionHeight = intersection.height
return intersectionHeight >= 0.5 * viewHeight
}
}
@@ -0,0 +1,7 @@
import {requireNativeModule} from 'expo-modules-core'
const NativeModule = requireNativeModule('ExpoPlatformInfo')
export function getIsReducedMotionEnabled(): boolean {
return NativeModule.getIsReducedMotionEnabled()
}
@@ -0,0 +1,5 @@
import {NotImplementedError} from '../NotImplemented'
export function getIsReducedMotionEnabled(): boolean {
throw new NotImplementedError()
}
@@ -0,0 +1,6 @@
export function getIsReducedMotionEnabled(): boolean {
if (typeof window === 'undefined') {
return false
}
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
@@ -0,0 +1,39 @@
import React from 'react'
import {StyleProp, ViewStyle} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {VisibilityViewProps} from './types'
const NativeView: React.ComponentType<{
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
children: React.ReactNode
enabled: Boolean
style: StyleProp<ViewStyle>
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
const NativeModule = requireNativeModule('ExpoBlueskyVisibilityView')
export async function updateActiveViewAsync() {
await NativeModule.updateActiveViewAsync()
}
export default function VisibilityView({
children,
onChangeStatus: onChangeStatusOuter,
enabled,
}: VisibilityViewProps) {
const onChangeStatus = React.useCallback(
(e: {nativeEvent: {isActive: boolean}}) => {
onChangeStatusOuter(e.nativeEvent.isActive)
},
[onChangeStatusOuter],
)
return (
<NativeView
onChangeStatus={onChangeStatus}
enabled={enabled}
style={{flex: 1}}>
{children}
</NativeView>
)
}
@@ -0,0 +1,10 @@
import {NotImplementedError} from '../NotImplemented'
import {VisibilityViewProps} from './types'
export async function updateActiveViewAsync() {
throw new NotImplementedError()
}
export default function VisibilityView({children}: VisibilityViewProps) {
return children
}
@@ -0,0 +1,6 @@
import React from 'react'
export interface VisibilityViewProps {
children: React.ReactNode
onChangeStatus: (isActive: boolean) => void
enabled: boolean
}
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.89.0",
"version": "1.90.0",
"private": true,
"engines": {
"node": ">=18"
@@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "0.12.25",
"@atproto/api": "0.12.29",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -143,6 +143,7 @@
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"hls.js": "^1.5.11",
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
@@ -269,7 +270,7 @@
"react-scripts": "^5.0.1",
"react-test-renderer": "18.2.0",
"ts-node": "^10.9.1",
"typescript": "^5.3.3",
"typescript": "^5.5.4",
"url-loader": "^4.1.1",
"webpack": "^5.75.0",
"webpack-bundle-analyzer": "^4.10.1",
+12
View File
@@ -12,3 +12,15 @@ index bb74e80..0aa0202 100644
Map<String, Object> constants = new HashMap<>(3);
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js
index 109d3fe..c7fce9e 100644
--- a/node_modules/expo-modules-core/build/uuid/uuid.js
+++ b/node_modules/expo-modules-core/build/uuid/uuid.js
@@ -1,5 +1,7 @@
import bytesToUuid from './lib/bytesToUuid';
import { Uuidv5Namespace } from './uuid.types';
+import { ensureNativeModulesAreInstalled } from '../ensureNativeModulesAreInstalled';
+ensureNativeModulesAreInstalled();
const nativeUuidv4 = globalThis?.expo?.uuidv4;
const nativeUuidv5 = globalThis?.expo?.uuidv5;
function uuidv4() {
+20
View File
@@ -0,0 +1,20 @@
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
setShowPreviousButton(!requireLinearPlayback)
setShowNextButton(!requireLinearPlayback)
setTimeBarInteractive(requireLinearPlayback)
+ setShowSubtitleButton(true)
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
- val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
+ val fullscreenButton =
+ findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
fullscreenButton?.visibility = if (visible) {
android.view.View.VISIBLE
} else {
@@ -207,31 +207,3 @@ index 88b3fdf..2488ebc 100644
const { layout, entering, exiting, sharedTransitionTag } = this.props;
if (
diff --git a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
index ac9be5d..86d4605 100644
--- a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
+++ b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
@@ -47,4 +47,5 @@ export { LayoutAnimationConfig } from './component/LayoutAnimationConfig';
export { PerformanceMonitor } from './component/PerformanceMonitor';
export { startMapper, stopMapper } from './mappers';
export { startScreenTransition, finishScreenTransition, ScreenTransition } from './screenTransition';
+export { isReducedMotion } from './PlatformChecker';
//# sourceMappingURL=index.js.map
diff --git a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
index f01dc57..161ef22 100644
--- a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
+++ b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
@@ -36,3 +36,4 @@ export type { FlatListPropsWithLayout } from './component/FlatList';
export { startMapper, stopMapper } from './mappers';
export { startScreenTransition, finishScreenTransition, ScreenTransition, } from './screenTransition';
export type { AnimatedScreenTransition, GoBackGesture, ScreenTransitionConfig, } from './screenTransition';
+export { isReducedMotion } from './PlatformChecker';
diff --git a/node_modules/react-native-reanimated/src/reanimated2/index.ts b/node_modules/react-native-reanimated/src/reanimated2/index.ts
index 5885fa1..a3c693f 100644
--- a/node_modules/react-native-reanimated/src/reanimated2/index.ts
+++ b/node_modules/react-native-reanimated/src/reanimated2/index.ts
@@ -284,3 +284,4 @@ export type {
GoBackGesture,
ScreenTransitionConfig,
} from './screenTransition';
+export { isReducedMotion } from './PlatformChecker';
+41 -38
View File
@@ -23,10 +23,12 @@ import {
} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {listenSessionDropped} from '#/state/events'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
@@ -49,6 +51,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
@@ -58,8 +61,6 @@ import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
SplashScreen.preventAutoHideAsync()
@@ -107,42 +108,44 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<TourProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</TourProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
<ActiveVideoProvider>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<TourProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</TourProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</ActiveVideoProvider>
</Splash>
</ThemeProvider>
</Alf>
+38 -35
View File
@@ -12,10 +12,12 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {listenSessionDropped} from '#/state/events'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
@@ -37,6 +39,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
@@ -46,8 +49,6 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -92,39 +93,41 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<TourProvider>
<ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</TourProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
<ActiveVideoProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<TourProvider>
<ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</TourProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
</ActiveVideoProvider>
</RootSiblingParent>
</ThemeProvider>
</Alf>
+9
View File
@@ -44,6 +44,7 @@ import HashtagScreen from '#/screens/Hashtag'
import {ModerationScreen} from '#/screens/Moderation'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {
StarterPackScreen,
StarterPackScreenShort,
@@ -310,6 +311,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="AppearanceSettings"
getComponent={() => AppearanceSettingsScreen}
options={{
title: title(msg`Appearance Settings`),
requireAuth: true,
}}
/>
<Stack.Screen
name="Hashtag"
getComponent={() => HashtagScreen}
+5 -6
View File
@@ -2,7 +2,6 @@ import {Platform, StyleSheet} from 'react-native'
import * as tokens from '#/alf/tokens'
import {native, web} from '#/alf/util/platform'
import hairlineWidth = StyleSheet.hairlineWidth
export const atoms = {
/*
@@ -284,19 +283,19 @@ export const atoms = {
borderWidth: 0,
},
border: {
borderWidth: hairlineWidth,
borderWidth: StyleSheet.hairlineWidth,
},
border_t: {
borderTopWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
},
border_b: {
borderBottomWidth: hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
},
border_l: {
borderLeftWidth: hairlineWidth,
borderLeftWidth: StyleSheet.hairlineWidth,
},
border_r: {
borderRightWidth: hairlineWidth,
borderRightWidth: StyleSheet.hairlineWidth,
},
/*
+3
View File
@@ -70,6 +70,9 @@ export type ButtonProps = Pick<
AccessibilityProps &
VariantProps & {
testID?: string
/**
* For a11y, try to make this descriptive and clear
*/
label: string
style?: StyleProp<ViewStyle>
hoverStyle?: StyleProp<ViewStyle>
+7 -3
View File
@@ -40,7 +40,7 @@ type Props = {
export function Default(props: Props) {
const {view} = props
return (
<Link label={view.displayName} {...props}>
<Link {...props}>
<Outer>
<Header>
<Avatar src={view.avatar} />
@@ -58,7 +58,7 @@ export function Link({
view,
children,
...props
}: Props & Omit<LinkProps, 'to'>) {
}: Props & Omit<LinkProps, 'to' | 'label'>) {
const queryClient = useQueryClient()
const href = React.useMemo(() => {
@@ -70,7 +70,11 @@ export function Link({
}, [view, queryClient])
return (
<InternalLink to={href} style={[a.flex_col]} {...props}>
<InternalLink
label={view.displayName}
to={href}
style={[a.flex_col]}
{...props}>
{children}
</InternalLink>
)
+15 -7
View File
@@ -92,14 +92,16 @@ function getRank(seenPost: SeenPost): string {
tier = 'a'
} else if (seenPost.feedContext?.startsWith('cluster')) {
tier = 'b'
} else if (seenPost.feedContext?.startsWith('ntpc')) {
} else if (seenPost.feedContext === 'popcluster') {
tier = 'c'
} else if (seenPost.feedContext?.startsWith('t-')) {
} else if (seenPost.feedContext?.startsWith('ntpc')) {
tier = 'd'
} else if (seenPost.feedContext === 'nettop') {
} else if (seenPost.feedContext?.startsWith('t-')) {
tier = 'e'
} else {
} else if (seenPost.feedContext === 'nettop') {
tier = 'f'
} else {
tier = 'g'
}
let score = Math.round(
Math.log(
@@ -190,7 +192,7 @@ export function SuggestedFollows() {
{profiles.slice(0, maxLength).map(profile => (
<ProfileCard.Link
key={profile.did}
did={profile.handle}
profile={profile}
onPress={() => {
logEvent('feed:interstitial:profileCard:press', {})
}}
@@ -266,7 +268,10 @@ export function SuggestedFollows() {
a.pt_xs,
a.gap_md,
]}>
<InlineLinkText to="/search" style={[t.atoms.text_contrast_medium]}>
<InlineLinkText
label={_(msg`Browse more suggestions`)}
to="/search"
style={[t.atoms.text_contrast_medium]}>
<Trans>Browse more suggestions</Trans>
</InlineLinkText>
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
@@ -396,7 +401,10 @@ export function SuggestedFeeds() {
a.pt_xs,
a.gap_md,
]}>
<InlineLinkText to="/search" style={[t.atoms.text_contrast_medium]}>
<InlineLinkText
label={_(msg`Browse more suggestions`)}
to="/search"
style={[t.atoms.text_contrast_medium]}>
<Trans>Browse more suggestions</Trans>
</InlineLinkText>
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
+4 -9
View File
@@ -40,11 +40,6 @@ type BaseLinkProps = Pick<
> & {
testID?: string
/**
* Label for a11y. Defaults to the href.
*/
label?: string
/**
* The React Navigation `StackAction` to perform when the link is pressed.
*/
@@ -197,7 +192,7 @@ export function useLink({
}
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
Omit<ButtonProps, 'onPress' | 'disabled'>
/**
* A interactive element that renders as a `<a>` tag on the web. On mobile it
@@ -224,7 +219,6 @@ export function Link({
return (
<Button
label={href}
{...rest}
style={[a.justify_start, flatten(rest.style)]}
role="link"
@@ -249,7 +243,8 @@ export function Link({
export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
>
> &
Pick<ButtonProps, 'label'>
export function InlineLinkText({
children,
@@ -291,7 +286,7 @@ export function InlineLinkText({
<Text
selectable={selectable}
accessibilityHint=""
accessibilityLabel={label || href}
accessibilityLabel={label}
{...rest}
style={[
{color: t.palette.primary_500},
+3 -3
View File
@@ -44,7 +44,7 @@ type Props = {
export function Default(props: Props) {
const {view, showPinButton} = props
return (
<Link label={view.name} {...props}>
<Link {...props}>
<Outer>
<Header>
<Avatar src={view.avatar} />
@@ -67,7 +67,7 @@ export function Link({
view,
children,
...props
}: Props & Omit<LinkProps, 'to'>) {
}: Props & Omit<LinkProps, 'to' | 'label'>) {
const queryClient = useQueryClient()
const href = React.useMemo(() => {
@@ -79,7 +79,7 @@ export function Link({
}, [view, queryClient])
return (
<InternalLink to={href} {...props}>
<InternalLink label={view.name} to={href} {...props}>
{children}
</InternalLink>
)
+10 -2
View File
@@ -122,8 +122,16 @@ export function ListHeaderDesktop({
if (!gtTablet) return null
return (
<View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
<Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
<View
style={[
a.w_full,
a.py_sm,
a.px_xl,
a.gap_xs,
a.justify_center,
{minHeight: 50},
]}>
<Text style={[a.text_2xl, a.font_bold]}>{title}</Text>
{subtitle ? (
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
{subtitle}
+12 -4
View File
@@ -36,7 +36,7 @@ export function Default({
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
}) {
return (
<Link did={profile.did}>
<Link profile={profile}>
<Card
profile={profile}
moderationOpts={moderationOpts}
@@ -96,16 +96,24 @@ export function Header({
}
export function Link({
did,
profile,
children,
style,
...rest
}: {did: string} & Omit<LinkProps, 'to'>) {
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
} & Omit<LinkProps, 'to' | 'label'>) {
const {_} = useLingui()
return (
<InternalLink
label={_(
msg`View ${
profile.displayName || sanitizeHandle(profile.handle)
}'s profile`,
)}
to={{
screen: 'Profile',
params: {name: did},
params: {name: profile.did},
}}
style={[a.flex_col, style]}
{...rest}>
+35 -21
View File
@@ -1,27 +1,27 @@
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 {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
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 {isInvalidHandle} from '#/lib/strings/handles'
import {
usePreferencesQuery,
useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {isInvalidHandle} from '#/lib/strings/handles'
import {Text} from '#/components/Typography'
export function useTagMenuControl() {
return Dialog.useDialogControl()
@@ -52,10 +52,10 @@ export function TagMenu({
reset: resetUpsert,
} = useUpsertMutedWordsMutation()
const {
mutateAsync: removeMutedWord,
mutateAsync: removeMutedWords,
variables: optimisticRemove,
reset: resetRemove,
} = useRemoveMutedWordMutation()
} = useRemoveMutedWordsMutation()
const displayTag = '#' + tag
const isMuted = Boolean(
@@ -65,9 +65,20 @@ export function TagMenu({
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
!(optimisticRemove?.value === tag),
!optimisticRemove?.find(m => m?.value === tag),
)
/*
* Mute word records that exactly match the tag in question.
*/
const removeableMuteWords = React.useMemo(() => {
return (
preferences?.moderationPrefs.mutedWords?.filter(word => {
return word.value === tag
}) || []
)
}, [tag, preferences?.moderationPrefs?.mutedWords])
return (
<>
{children}
@@ -212,13 +223,16 @@ export function TagMenu({
control.close(() => {
if (isMuted) {
resetUpsert()
removeMutedWord({
value: tag,
targets: ['tag'],
})
removeMutedWords(removeableMuteWords)
} else {
resetRemove()
upsertMutedWord([{value: tag, targets: ['tag']}])
upsertMutedWord([
{
value: tag,
targets: ['tag'],
actorTarget: 'all',
},
])
}
})
}}>
+25 -11
View File
@@ -3,16 +3,16 @@ 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 {isInvalidHandle} from '#/lib/strings/handles'
import {enforceLen} from '#/lib/strings/helpers'
import {
usePreferencesQuery,
useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {enforceLen} from '#/lib/strings/helpers'
import {EventStopper} from '#/view/com/util/EventStopper'
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
import {web} from '#/alf'
import * as Dialog from '#/components/Dialog'
@@ -47,8 +47,8 @@ export function TagMenu({
const {data: preferences} = usePreferencesQuery()
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
useUpsertMutedWordsMutation()
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
useRemoveMutedWordMutation()
const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
useRemoveMutedWordsMutation()
const isMuted = Boolean(
(preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
@@ -56,10 +56,21 @@ export function TagMenu({
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
!(optimisticRemove?.value === tag),
!optimisticRemove?.find(m => m?.value === tag),
)
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
/*
* Mute word records that exactly match the tag in question.
*/
const removeableMuteWords = React.useMemo(() => {
return (
preferences?.moderationPrefs.mutedWords?.filter(word => {
return word.value === tag
}) || []
)
}, [tag, preferences?.moderationPrefs?.mutedWords])
const dropdownItems = React.useMemo(() => {
return [
{
@@ -105,9 +116,11 @@ export function TagMenu({
: _(msg`Mute ${truncatedTag}`),
onPress() {
if (isMuted) {
removeMutedWord({value: tag, targets: ['tag']})
removeMutedWords(removeableMuteWords)
} else {
upsertMutedWord([{value: tag, targets: ['tag']}])
upsertMutedWord([
{value: tag, targets: ['tag'], actorTarget: 'all'},
])
}
},
testID: 'tagMenuMute',
@@ -129,7 +142,8 @@ export function TagMenu({
tag,
truncatedTag,
upsertMutedWord,
removeMutedWord,
removeMutedWords,
removeableMuteWords,
])
return (
+283 -90
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {Keyboard, View} from 'react-native'
import {View} from 'react-native'
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -24,6 +24,7 @@ import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {useFormatDistance} from '#/components/hooks/dates'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
@@ -32,6 +33,8 @@ import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
const ONE_DAY = 24 * 60 * 60 * 1000
export function MutedWordsDialog() {
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
return (
@@ -53,16 +56,32 @@ function MutedWordsInner() {
} = usePreferencesQuery()
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
const [field, setField] = React.useState('')
const [options, setOptions] = React.useState(['content'])
const [targets, setTargets] = React.useState(['content'])
const [error, setError] = React.useState('')
const [durations, setDurations] = React.useState(['forever'])
const [excludeFollowing, setExcludeFollowing] = React.useState(false)
const submit = React.useCallback(async () => {
const sanitizedValue = sanitizeMutedWordValue(field)
const targets = ['tag', options.includes('content') && 'content'].filter(
const surfaces = ['tag', targets.includes('content') && 'content'].filter(
Boolean,
) as AppBskyActorDefs.MutedWord['targets']
const actorTarget = excludeFollowing ? 'exclude-following' : 'all'
if (!sanitizedValue || !targets.length) {
const now = Date.now()
const rawDuration = durations.at(0)
// undefined evaluates to 'forever'
let duration: string | undefined
if (rawDuration === '24_hours') {
duration = new Date(now + ONE_DAY).toISOString()
} else if (rawDuration === '7_days') {
duration = new Date(now + 7 * ONE_DAY).toISOString()
} else if (rawDuration === '30_days') {
duration = new Date(now + 30 * ONE_DAY).toISOString()
}
if (!sanitizedValue || !surfaces.length) {
setField('')
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
return
@@ -70,28 +89,37 @@ function MutedWordsInner() {
try {
// send raw value and rely on SDK as sanitization source of truth
await addMutedWord([{value: field, targets}])
await addMutedWord([
{
value: field,
targets: surfaces,
actorTarget,
expiresAt: duration,
},
])
setField('')
} catch (e: any) {
logger.error(`Failed to save muted word`, {message: e.message})
setError(e.message)
}
}, [_, field, options, addMutedWord, setField])
}, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
return (
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
<View onTouchStart={Keyboard.dismiss}>
<View>
<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.
Posts can be muted based on their text, their tags, or both. We
recommend avoiding common words that appear in many posts, since it
can result in no posts being shown.
</Trans>
</Text>
<View style={[a.pb_lg]}>
<View style={[a.pb_sm]}>
<Dialog.Input
autoCorrect={false}
autoCapitalize="none"
@@ -107,30 +135,135 @@ function MutedWordsInner() {
}}
onSubmitEditing={submit}
/>
</View>
<View style={[a.pb_xl, a.gap_sm]}>
<Toggle.Group
label={_(msg`Toggle between muted word options.`)}
label={_(msg`Select how long to mute this word for.`)}
type="radio"
values={options}
onChange={setOptions}>
values={durations}
onChange={setDurations}>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Duration:</Trans>
</Text>
<View
style={[
a.pt_sm,
a.py_sm,
a.flex_row,
a.align_center,
gtMobile && [a.flex_row, a.align_center, a.justify_start],
a.gap_sm,
a.flex_wrap,
]}>
<View
style={[
a.flex_1,
a.flex_row,
a.justify_start,
a.align_center,
a.gap_sm,
]}>
<Toggle.Item
label={_(msg`Mute this word until you unmute it`)}
name="forever"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Forever</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word for 24 hours`)}
name="24_hours"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>24 hours</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
<View
style={[
a.flex_1,
a.flex_row,
a.justify_start,
a.align_center,
a.gap_sm,
]}>
<Toggle.Item
label={_(msg`Mute this word for 7 days`)}
name="7_days"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>7 days</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word for 30 days`)}
name="30_days"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>30 days</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
</View>
</Toggle.Group>
<Toggle.Group
label={_(msg`Select what content this mute word should apply to.`)}
type="radio"
values={targets}
onChange={setTargets}>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Mute in:</Trans>
</Text>
<View style={[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]]}>
style={[a.flex_1]}>
<TargetToggle>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Mute in text & tags</Trans>
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Text & tags</Trans>
</Toggle.LabelText>
</View>
<PageText size="sm" />
@@ -140,34 +273,64 @@ function MutedWordsInner() {
<Toggle.Item
label={_(msg`Mute this word in tags only`)}
name="tag"
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
style={[a.flex_1]}>
<TargetToggle>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Mute in tags only</Trans>
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Tags only</Trans>
</Toggle.LabelText>
</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>
<View>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Options:</Trans>
</Text>
<Toggle.Item
label={_(msg`Do not apply this mute word to users you follow`)}
name="exclude_following"
style={[a.flex_row, a.justify_between]}
value={excludeFollowing}
onChange={setExcludeFollowing}>
<TargetToggle>
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Exclude users you follow</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
<View style={[a.pt_xs]}>
<Button
disabled={isPending || !field}
label={_(msg`Add mute word for configured settings`)}
size="medium"
color="primary"
variant="solid"
style={[]}
onPress={submit}>
<ButtonText>
<Trans>Add</Trans>
</ButtonText>
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
</Button>
</View>
{error && (
<View
style={[
@@ -191,20 +354,6 @@ function MutedWordsInner() {
</Text>
</View>
)}
<Text
style={[
a.pt_xs,
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 />
@@ -268,6 +417,9 @@ function MutedWordRow({
const {_} = useLingui()
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
const control = Prompt.usePromptControl()
const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined
const isExpired = expiryDate && expiryDate < new Date()
const formatDistance = useFormatDistance()
const remove = React.useCallback(async () => {
control.close()
@@ -280,7 +432,7 @@ function MutedWordRow({
control={control}
title={_(msg`Are you sure?`)}
description={_(
msg`This will delete ${word.value} from your muted words. You can always add it back later.`,
msg`This will delete "${word.value}" from your muted words. You can always add it back later.`,
)}
onConfirm={remove}
confirmButtonCta={_(msg`Remove`)}
@@ -289,53 +441,94 @@ function MutedWordRow({
<View
style={[
a.flex_row,
a.justify_between,
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_1, a.gap_xs]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Text
style={[
a.flex_1,
a.leading_snug,
a.font_bold,
web({
overflowWrap: 'break-word',
wordBreak: 'break-word',
}),
]}>
{word.targets.find(t => t === 'content') ? (
<Trans comment="Pattern: {wordValue} in text, tags">
{word.value}{' '}
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
in{' '}
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
text & tags
</Text>
</Text>
</Trans>
) : (
<Trans comment="Pattern: {wordValue} in tags">
{word.value}{' '}
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
in{' '}
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
tags
</Text>
</Text>
</Trans>
)}
</Text>
</View>
<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]}>
{(expiryDate || word.actorTarget === 'exclude-following') && (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Text
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
style={[
a.flex_1,
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{expiryDate && (
<>
{isExpired ? (
<Trans>Expired</Trans>
) : (
<Trans>
Expires{' '}
{formatDistance(expiryDate, new Date(), {
addSuffix: true,
})}
</Trans>
)}
</>
)}
{word.actorTarget === 'exclude-following' && (
<>
{' '}
<Trans>Excludes users you follow</Trans>
</>
)}
</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>
<Button
label={_(msg`Remove mute word from your list`)}
size="tiny"
shape="round"
variant="outline"
color="secondary"
onPress={() => control.open()}
style={[a.ml_sm]}>
<ButtonIcon icon={isPending ? Loader : X} />
</Button>
</View>
</>
)
+3 -1
View File
@@ -74,7 +74,9 @@ function DialogContent({
const onPressAudience = (setting: ThreadgateSetting) => {
// remove nobody
let newSelected = draft.filter(v => v.type !== 'nobody')
let newSelected: ThreadgateSetting[] = draft.filter(
v => v.type !== 'nobody',
)
// toggle
const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) {
@@ -42,6 +42,7 @@ export function BlockedByListDialog({
<React.Fragment key={block.source.list.uri}>
{i === 0 ? null : ', '}
<InlineLinkText
label={block.source.list.name}
to={listUriToHref(block.source.list.uri)}
style={[a.text_md, a.leading_snug]}>
{block.source.list.name}
@@ -140,6 +140,7 @@ function HeaderReady({
userBlock?: ModerationCause
}
}) {
const {_} = useLingui()
const t = useTheme()
const convoState = useConvo()
const profile = useProfileShadow(profileUnshadowed)
@@ -156,6 +157,7 @@ function HeaderReady({
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
<Link
label={_(msg`View ${displayName}'s profile`)}
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<View style={[a.pt_2xs]}>
+1 -1
View File
@@ -23,10 +23,10 @@ export function Group({children, multiple, ...props}: GroupProps) {
style={[
a.w_full,
a.flex_row,
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border_contrast_low,
{borderWidth: 1},
]}>
{children}
</View>
+69
View File
@@ -0,0 +1,69 @@
/**
* Hooks for date-fns localized formatters.
*
* Our app supports some languages that are not included in date-fns by
* default, in which case it will fall back to English.
*
* {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md}
*/
import React from 'react'
import {formatDistance, Locale} from 'date-fns'
import {
ca,
de,
es,
fi,
fr,
hi,
id,
it,
ja,
ko,
ptBR,
tr,
uk,
zhCN,
zhTW,
} from 'date-fns/locale'
import {AppLanguage} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences'
/**
* {@link AppLanguage}
*/
const locales: Record<AppLanguage, Locale | undefined> = {
en: undefined,
ca,
de,
es,
fi,
fr,
ga: undefined,
hi,
id,
it,
ja,
ko,
['pt-BR']: ptBR,
tr,
uk,
['zh-CN']: zhCN,
['zh-TW']: zhTW,
}
/**
* Returns a localized `formatDistance` function.
* {@link formatDistance}
*/
export function useFormatDistance() {
const {appLanguage} = useLanguagePrefs()
return React.useCallback<typeof formatDistance>(
(date, baseDate, options) => {
const locale = locales[appLanguage as AppLanguage]
return formatDistance(date, baseDate, {...options, locale: locale})
},
[appLanguage],
)
}
+17
View File
@@ -0,0 +1,17 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowsDiagonalOut_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z',
})
export const ArrowsDiagonalIn_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
})
export const ArrowsDiagonalOut_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z',
})
export const ArrowsDiagonalIn_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
})
+9
View File
@@ -0,0 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'
export const CC_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z',
})
export const CC_Filled_Corner0_Rounded = createSinglePathSVG({
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Moon_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12.097 2.53a1 1 0 0 1-.041 1.07 6 6 0 0 0 8.345 8.344 1 1 0 0 1 1.563.908c-.434 5.122-4.728 9.144-9.962 9.144-5.522 0-9.998-4.476-9.998-9.998 0-5.234 4.021-9.528 9.144-9.962a1 1 0 0 1 .949.494ZM9.424 4.424a7.998 7.998 0 1 0 10.152 10.152A8 8 0 0 1 9.424 4.424Z',
})
+17
View File
@@ -0,0 +1,17 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Pause_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z',
})
export const Pause_Filled_Corner0_Rounded = createSinglePathSVG({
path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z',
})
export const Pause_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z',
})
export const Pause_Filled_Corner2_Rounded = createSinglePathSVG({
path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Phone_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M5 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z',
})
+17
View File
@@ -0,0 +1,17 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Play_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z',
})
export const Play_Filled_Corner0_Rounded = createSinglePathSVG({
path: 'M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z',
})
export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z',
})
export const Play_Filled_Corner2_Rounded = createSinglePathSVG({
path: 'M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z',
})
@@ -174,7 +174,7 @@ export function LabelerLabelPreference({
disabled?: boolean
labelerDid?: string
}) {
const {i18n} = useLingui()
const {_, i18n} = useLingui()
const t = useTheme()
const {gtPhone} = useBreakpoints()
@@ -243,7 +243,10 @@ export function LabelerLabelPreference({
) : isGlobalLabel ? (
<Trans>
Configured in{' '}
<InlineLinkText to="/moderation" style={a.text_sm}>
<InlineLinkText
label={_(msg`moderation settings`)}
to="/moderation"
style={a.text_sm}>
moderation settings
</InlineLinkText>
.
+10 -4
View File
@@ -128,6 +128,9 @@ function Label({
const t = useTheme()
const {_} = useLingui()
const {labeler, strings} = useLabelInfo(label)
const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src
return (
<View
style={[
@@ -169,13 +172,12 @@ function Label({
<Trans>
Source:{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}>
{labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src}
{sourceName}
</InlineLinkText>
</Trans>
)}
@@ -203,6 +205,9 @@ function AppealForm({
const isAccountReport = 'did' in subject
const agent = useAgent()
const gate = useGate()
const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src
const {mutate, isPending} = useMutation({
mutationFn: async () => {
@@ -260,12 +265,13 @@ function AppealForm({
<Trans>
This appeal will be sent to{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}
style={[a.text_md, a.leading_snug]}>
{labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
{sourceName}
</InlineLinkText>
.
</Trans>
@@ -54,7 +54,10 @@ function ModerationDetailsDialogInner({
description = (
<Trans>
This user is included in the{' '}
<InlineLinkText to={listUriToHref(list.uri)} style={[a.text_sm]}>
<InlineLinkText
label={list.name}
to={listUriToHref(list.uri)}
style={[a.text_sm]}>
{list.name}
</InlineLinkText>{' '}
list which you have blocked.
@@ -83,7 +86,10 @@ function ModerationDetailsDialogInner({
description = (
<Trans>
This user is included in the{' '}
<InlineLinkText to={listUriToHref(list.uri)} style={[a.text_sm]}>
<InlineLinkText
label={list.name}
to={listUriToHref(list.uri)}
style={[a.text_sm]}>
{list.name}
</InlineLinkText>{' '}
list which you have muted.
@@ -127,10 +133,11 @@ function ModerationDetailsDialogInner({
<Trans>
This label was applied by{' '}
<InlineLinkText
label={desc.source || _(msg`an unknown labeler`)}
to={makeProfileLink({did: modcause.label.src, handle: ''})}
onPress={() => control.close()}
style={a.text_md}>
{desc.source}
{desc.source || _(msg`an unknown labeler`)}
</InlineLinkText>
.
</Trans>
+15 -6
View File
@@ -14,7 +14,7 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {CenteredView} from '#/view/com/util/Views'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {
ModerationDetailsDialog,
@@ -105,6 +105,7 @@ export function ScreenHider({
a.mb_md,
a.px_lg,
a.text_center,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{isNoPwi ? (
@@ -113,8 +114,15 @@ export function ScreenHider({
</Trans>
) : (
<>
<Trans>This {screenDescription} has been flagged:</Trans>
<Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}>
<Trans>This {screenDescription} has been flagged:</Trans>{' '}
<Text
style={[
a.text_lg,
a.font_semibold,
a.leading_snug,
t.atoms.text,
a.ml_xs,
]}>
{desc.name}.{' '}
</Text>
<TouchableWithoutFeedback
@@ -127,16 +135,17 @@ export function ScreenHider({
<Text
style={[
a.text_lg,
a.leading_snug,
{
color: t.palette.primary_500,
// @ts-ignore web only -prf
cursor: 'pointer',
},
web({
cursor: 'pointer',
}),
]}>
<Trans>Learn More</Trans>
</Text>
</TouchableWithoutFeedback>
<ModerationDetailsDialog control={control} modcause={blur} />
</>
)}{' '}
+336 -261
View File
@@ -1,4 +1,5 @@
import {
AppBskyActorDefs,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
@@ -6,328 +7,351 @@ import {
} from '@atproto/api'
import {isPostInLanguage} from '../../locale/helpers'
import {FALLBACK_MARKER_POST} from './feed/home'
import {ReasonFeedSource} from './feed/types'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
export type FeedTunerFn = (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
dryRun: boolean,
) => FeedViewPostsSlice[]
type FeedSliceItem = {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
isParentBlocked: boolean
}
type AuthorContext = {
author: AppBskyActorDefs.ProfileViewBasic
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
}
export class FeedViewPostsSlice {
_reactKey: string
isFlattenedReply = false
_feedPost: FeedViewPost
items: FeedSliceItem[]
isIncompleteThread: boolean
isFallbackMarker: boolean
isOrphan: boolean
rootUri: string
constructor(public items: FeedViewPost[]) {
const item = items[0]
this._reactKey = `slice-${item.post.uri}-${
item.reason?.indexedAt || item.post.indexedAt
constructor(feedPost: FeedViewPost) {
const {post, reply, reason} = feedPost
this.items = []
this.isIncompleteThread = false
this.isFallbackMarker = false
this.isOrphan = false
if (AppBskyFeedDefs.isPostView(reply?.root)) {
this.rootUri = reply.root.uri
} else {
this.rootUri = post.uri
}
this._feedPost = feedPost
this._reactKey = `slice-${post.uri}-${
feedPost.reason?.indexedAt || post.indexedAt
}`
}
get uri() {
if (this.isFlattenedReply) {
return this.items[1].post.uri
if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) {
this.isFallbackMarker = true
return
}
return this.items[0].post.uri
}
get ts() {
if (this.items[0].reason?.indexedAt) {
return this.items[0].reason.indexedAt as string
if (
!AppBskyFeedPost.isRecord(post.record) ||
!AppBskyFeedPost.validateRecord(post.record).success
) {
return
}
return this.items[0].post.indexedAt
}
get isThread() {
return (
this.items.length > 1 &&
this.items.every(
item => item.post.author.did === this.items[0].post.author.did,
)
const parent = reply?.parent
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
if (AppBskyFeedDefs.isPostView(parent)) {
parentAuthor = parent.author
}
this.items.push({
post,
record: post.record,
parentAuthor,
isParentBlocked,
})
if (!reply || reason) {
return
}
if (
!AppBskyFeedDefs.isPostView(parent) ||
!AppBskyFeedPost.isRecord(parent.record) ||
!AppBskyFeedPost.validateRecord(parent.record).success
) {
this.isOrphan = true
return
}
const grandparentAuthor = reply.grandparentAuthor
const isGrandparentBlocked = Boolean(
grandparentAuthor?.viewer?.blockedBy ||
grandparentAuthor?.viewer?.blocking ||
grandparentAuthor?.viewer?.blockingByList,
)
}
get isFullThread() {
return this.isThread && !this.items[0].reply
}
get rootItem() {
if (this.isFlattenedReply) {
return this.items[1]
this.items.unshift({
post: parent,
record: parent.record,
parentAuthor: grandparentAuthor,
isParentBlocked: isGrandparentBlocked,
})
if (isGrandparentBlocked) {
this.isOrphan = true
// Keep going, it might still have a root.
}
return this.items[0]
const root = reply.root
if (
!AppBskyFeedDefs.isPostView(root) ||
!AppBskyFeedPost.isRecord(root.record) ||
!AppBskyFeedPost.validateRecord(root.record).success
) {
this.isOrphan = true
return
}
if (root.uri === parent.uri) {
return
}
this.items.unshift({
post: root,
record: root.record,
isParentBlocked: false,
parentAuthor: undefined,
})
if (parent.record.reply?.parent.uri !== root.uri) {
this.isIncompleteThread = true
}
}
get isQuotePost() {
const embed = this._feedPost.post.embed
return (
AppBskyEmbedRecord.isView(embed) ||
AppBskyEmbedRecordWithMedia.isView(embed)
)
}
get isReply() {
return (
AppBskyFeedPost.isRecord(this.rootItem.post.record) &&
!!this.rootItem.post.record.reply
AppBskyFeedPost.isRecord(this._feedPost.post.record) &&
!!this._feedPost.post.record.reply
)
}
get source(): ReasonFeedSource | undefined {
return this.items.find(item => '__source' in item && !!item.__source)
?.__source as ReasonFeedSource
get reason() {
return '__source' in this._feedPost
? (this._feedPost.__source as ReasonFeedSource)
: this._feedPost.reason
}
get feedContext() {
return this.items.find(item => item.feedContext)?.feedContext
return this._feedPost.feedContext
}
get isRepost() {
const reason = this._feedPost.reason
return AppBskyFeedDefs.isReasonRepost(reason)
}
get likeCount() {
return this._feedPost.post.likeCount ?? 0
}
containsUri(uri: string) {
return !!this.items.find(item => item.post.uri === uri)
}
isNextInThread(uri: string) {
return this.items[this.items.length - 1].post.uri === uri
}
insert(item: FeedViewPost) {
const selfReplyUri = getSelfReplyUri(item)
const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri)
if (i !== -1) {
this.items.splice(i + 1, 0, item)
} else {
this.items.push(item)
}
}
flattenReplyParent() {
if (this.items[0].reply) {
const reply = this.items[0].reply
if (AppBskyFeedDefs.isPostView(reply.parent)) {
this.isFlattenedReply = true
this.items.splice(0, 0, {post: reply.parent})
getAuthors(): AuthorContext {
const feedPost = this._feedPost
let author: AppBskyActorDefs.ProfileViewBasic = feedPost.post.author
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
let grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
let rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
if (feedPost.reply) {
if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) {
parentAuthor = feedPost.reply.parent.author
}
if (feedPost.reply.grandparentAuthor) {
grandparentAuthor = feedPost.reply.grandparentAuthor
}
if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) {
rootAuthor = feedPost.reply.root.author
}
}
}
isFollowingAllAuthors(userDid: string) {
const item = this.rootItem
if (item.post.author.did === userDid) {
return true
return {
author,
parentAuthor,
grandparentAuthor,
rootAuthor,
}
if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
const parent = item.reply?.parent
if (parent?.author.did === userDid) {
return true
}
return (
parent?.author.viewer?.following && item.post.author.viewer?.following
)
}
return false
}
}
export class NoopFeedTuner {
reset() {}
tune(
feed: FeedViewPost[],
_opts?: {dryRun: boolean; maintainOrder: boolean},
): FeedViewPostsSlice[] {
return feed.map(item => new FeedViewPostsSlice([item]))
}
}
export class FeedTuner {
seenKeys: Set<string> = new Set()
seenUris: Set<string> = new Set()
seenRootUris: Set<string> = new Set()
constructor(public tunerFns: FeedTunerFn[]) {}
reset() {
this.seenKeys.clear()
this.seenUris.clear()
}
tune(
feed: FeedViewPost[],
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
{dryRun}: {dryRun: boolean} = {
dryRun: false,
maintainOrder: false,
},
): FeedViewPostsSlice[] {
let slices: FeedViewPostsSlice[] = []
let slices: FeedViewPostsSlice[] = feed
.map(item => new FeedViewPostsSlice(item))
.filter(s => s.items.length > 0 || s.isFallbackMarker)
// remove posts that are replies, but which don't have the parent
// hydrated. this means the parent was either deleted or blocked
feed = feed.filter(item => {
if (
AppBskyFeedPost.isRecord(item.post.record) &&
item.post.record.reply &&
!item.reply
) {
// run the custom tuners
for (const tunerFn of this.tunerFns) {
slices = tunerFn(this, slices.slice(), dryRun)
}
slices = slices.filter(slice => {
if (this.seenKeys.has(slice._reactKey)) {
return false
}
// Some feeds, like Following, dedupe by thread, so you only see the most recent reply.
// However, we don't want per-thread dedupe for author feeds (where we need to show every post)
// or for feedgens (where we want to let the feed serve multiple replies if it chooses to).
// To avoid showing the same context (root and/or parent) more than once, we do last resort
// per-post deduplication. It hides already seen posts as long as this doesn't break the thread.
for (let i = 0; i < slice.items.length; i++) {
const item = slice.items[i]
if (this.seenUris.has(item.post.uri)) {
if (i === 0) {
// Omit contiguous seen leading items.
// For example, [A -> B -> C], [A -> D -> E], [A -> D -> F]
// would turn into [A -> B -> C], [D -> E], [F].
slice.items.splice(0, 1)
i--
}
if (i === slice.items.length - 1) {
// If the last item in the slice was already seen, omit the whole slice.
// This means we'd miss its parents, but the user can "show more" to see them.
// For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C]
// would get collapsed into [A ... E -> F], with B/C/D considered seen.
return false
}
} else {
if (!dryRun) {
this.seenUris.add(item.post.uri)
}
}
}
if (!dryRun) {
this.seenKeys.add(slice._reactKey)
}
return true
})
if (maintainOrder) {
slices = feed.map(item => new FeedViewPostsSlice([item]))
} else {
// arrange the posts into thread slices
for (let i = feed.length - 1; i >= 0; i--) {
const item = feed[i]
const selfReplyUri = getSelfReplyUri(item)
if (selfReplyUri) {
const index = slices.findIndex(slice =>
slice.isNextInThread(selfReplyUri),
)
if (index !== -1) {
const parent = slices[index]
parent.insert(item)
// If our slice isn't currently on the top, reinsert it to the top.
if (index !== 0) {
slices.splice(index, 1)
slices.unshift(parent)
}
continue
}
}
slices.unshift(new FeedViewPostsSlice([item]))
}
}
// run the custom tuners
for (const tunerFn of this.tunerFns) {
slices = tunerFn(this, slices.slice())
}
// remove any items already "seen"
const soonToBeSeenUris: Set<string> = new Set()
for (let i = slices.length - 1; i >= 0; i--) {
if (!slices[i].isThread && this.seenUris.has(slices[i].uri)) {
slices.splice(i, 1)
} else {
for (const item of slices[i].items) {
soonToBeSeenUris.add(item.post.uri)
}
}
}
// turn non-threads with reply parents into threads
for (const slice of slices) {
if (!slice.isThread && !slice.items[0].reason && slice.items[0].reply) {
const reply = slice.items[0].reply
if (
AppBskyFeedDefs.isPostView(reply.parent) &&
!this.seenUris.has(reply.parent.uri) &&
!soonToBeSeenUris.has(reply.parent.uri)
) {
const uri = reply.parent.uri
slice.flattenReplyParent()
soonToBeSeenUris.add(uri)
}
}
}
if (!dryRun) {
slices = slices.filter(slice => {
if (this.seenKeys.has(slice._reactKey)) {
return false
}
for (const item of slice.items) {
this.seenUris.add(item.post.uri)
}
this.seenKeys.add(slice._reactKey)
return true
})
}
return slices
}
static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
for (let i = slices.length - 1; i >= 0; i--) {
if (slices[i].isReply) {
slices.splice(i, 1)
}
}
return slices
}
static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
for (let i = slices.length - 1; i >= 0; i--) {
const reason = slices[i].rootItem.reason
if (AppBskyFeedDefs.isReasonRepost(reason)) {
slices.splice(i, 1)
}
}
return slices
}
static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
for (let i = slices.length - 1; i >= 0; i--) {
const embed = slices[i].rootItem.post.embed
if (
AppBskyEmbedRecord.isView(embed) ||
AppBskyEmbedRecordWithMedia.isView(embed)
) {
slices.splice(i, 1)
}
}
return slices
}
static dedupReposts(
static removeReplies(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
): FeedViewPostsSlice[] {
// remove duplicates caused by reposts
_dryRun: boolean,
) {
for (let i = 0; i < slices.length; i++) {
const item1 = slices[i]
for (let j = i + 1; j < slices.length; j++) {
const item2 = slices[j]
if (item2.isThread) {
// dont dedup items that are rendering in a thread as this can cause rendering errors
continue
}
if (item1.containsUri(item2.items[0].post.uri)) {
slices.splice(j, 1)
j--
const slice = slices[i]
if (
slice.isReply &&
!slice.isRepost &&
// This is not perfect but it's close as we can get to
// detecting threads without having to peek ahead.
!areSameAuthor(slice.getAuthors())
) {
slices.splice(i, 1)
i--
}
}
return slices
}
static removeReposts(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
_dryRun: boolean,
) {
for (let i = 0; i < slices.length; i++) {
if (slices[i].isRepost) {
slices.splice(i, 1)
i--
}
}
return slices
}
static removeQuotePosts(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
_dryRun: boolean,
) {
for (let i = 0; i < slices.length; i++) {
if (slices[i].isQuotePost) {
slices.splice(i, 1)
i--
}
}
return slices
}
static removeOrphans(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
_dryRun: boolean,
) {
for (let i = 0; i < slices.length; i++) {
if (slices[i].isOrphan) {
slices.splice(i, 1)
i--
}
}
return slices
}
static dedupThreads(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
dryRun: boolean,
): FeedViewPostsSlice[] {
for (let i = 0; i < slices.length; i++) {
const rootUri = slices[i].rootUri
if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) {
slices.splice(i, 1)
i--
} else {
if (!dryRun) {
tuner.seenRootUris.add(rootUri)
}
}
}
return slices
}
static thresholdRepliesOnly({
userDid,
minLikes,
followedOnly,
}: {
userDid: string
minLikes: number
followedOnly: boolean
}) {
static followedRepliesOnly({userDid}: {userDid: string}) {
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
_dryRun: boolean,
): FeedViewPostsSlice[] => {
// remove any replies without at least minLikes likes
for (let i = slices.length - 1; i >= 0; i--) {
for (let i = 0; i < slices.length; i++) {
const slice = slices[i]
if (slice.isFullThread || !slice.isReply) {
continue
}
const item = slice.rootItem
const isRepost = Boolean(item.reason)
if (isRepost) {
continue
}
if ((item.post.likeCount || 0) < minLikes) {
slices.splice(i, 1)
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
if (
slice.isReply &&
!slice.isRepost &&
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
) {
slices.splice(i, 1)
i--
}
}
return slices
@@ -345,6 +369,7 @@ export class FeedTuner {
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
_dryRun: boolean,
): FeedViewPostsSlice[] => {
const candidateSlices = slices.slice()
@@ -353,7 +378,7 @@ export class FeedTuner {
return slices
}
for (let i = slices.length - 1; i >= 0; i--) {
for (let i = 0; i < slices.length; i++) {
let hasPreferredLang = false
for (const item of slices[i].items) {
if (isPostInLanguage(item.post, preferredLangsCode2)) {
@@ -379,16 +404,66 @@ export class FeedTuner {
}
}
function getSelfReplyUri(item: FeedViewPost): string | undefined {
if (item.reply) {
if (
AppBskyFeedDefs.isPostView(item.reply.parent) &&
!AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies
) {
return item.reply.parent.author.did === item.post.author.did
? item.reply.parent.uri
: undefined
}
function areSameAuthor(authors: AuthorContext): boolean {
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
const authorDid = author.did
if (parentAuthor && parentAuthor.did !== authorDid) {
return false
}
return undefined
if (grandparentAuthor && grandparentAuthor.did !== authorDid) {
return false
}
if (rootAuthor && rootAuthor.did !== authorDid) {
return false
}
return true
}
function shouldDisplayReplyInFollowing(
authors: AuthorContext,
userDid: string,
): boolean {
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
if (!isSelfOrFollowing(author, userDid)) {
// Only show replies from self or people you follow.
return false
}
if (
(!parentAuthor || parentAuthor.did === author.did) &&
(!rootAuthor || rootAuthor.did === author.did) &&
(!grandparentAuthor || grandparentAuthor.did === author.did)
) {
// Always show self-threads.
return true
}
// From this point on we need at least one more reason to show it.
if (
parentAuthor &&
parentAuthor.did !== author.did &&
isSelfOrFollowing(parentAuthor, userDid)
) {
return true
}
if (
grandparentAuthor &&
grandparentAuthor.did !== author.did &&
isSelfOrFollowing(grandparentAuthor, userDid)
) {
return true
}
if (
rootAuthor &&
rootAuthor.did !== author.did &&
isSelfOrFollowing(rootAuthor, userDid)
) {
return true
}
return false
}
function isSelfOrFollowing(
profile: AppBskyActorDefs.ProfileViewBasic,
userDid: string,
) {
return Boolean(profile.did === userDid || profile.viewer?.following)
}
+1 -13
View File
@@ -193,12 +193,6 @@ class MergeFeedSource {
return this.hasMore && this.queue.length === 0
}
reset() {
this.cursor = undefined
this.queue = []
this.hasMore = true
}
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
return this.queue.splice(0, n)
}
@@ -232,11 +226,6 @@ class MergeFeedSource {
class MergeFeedSource_Following extends MergeFeedSource {
tuner = new FeedTuner(this.feedTuners)
reset() {
super.reset()
this.tuner.reset()
}
async fetchNext(n: number) {
return this._fetchNextInner(n)
}
@@ -249,9 +238,8 @@ class MergeFeedSource_Following extends MergeFeedSource {
// run the tuner pre-emptively to ensure better mixing
const slices = this.tuner.tune(res.data.feed, {
dryRun: false,
maintainOrder: true,
})
res.data.feed = slices.map(slice => slice.rootItem)
res.data.feed = slices.map(slice => slice._feedPost)
return res
}
}
+4
View File
@@ -54,6 +54,10 @@ interface PostOpts {
uri: string
cid: string
}
video?: {
uri: string
cid: string
}
extLink?: ExternalEmbedDraft
images?: ImageModel[]
labels?: string[]
+2 -2
View File
@@ -3,7 +3,7 @@ import React from 'react'
export const useDedupe = () => {
const canDo = React.useRef(true)
return React.useRef((cb: () => unknown) => {
return React.useCallback((cb: () => unknown) => {
if (canDo.current) {
canDo.current = false
setTimeout(() => {
@@ -13,5 +13,5 @@ export const useDedupe = () => {
return true
}
return false
}).current
}, [])
}
+36
View File
@@ -0,0 +1,36 @@
/**
* TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION.
* @temporary
* PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented.
* Not joking, this is temporary.
*/
export interface JobStatus {
jobId: string
did: string
cid: string
state: JobState
progress?: number
errorHuman?: string
errorMachine?: string
}
export enum JobState {
JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED',
JOB_STATE_CREATED = 'JOB_STATE_CREATED',
JOB_STATE_ENCODING = 'JOB_STATE_ENCODING',
JOB_STATE_ENCODED = 'JOB_STATE_ENCODED',
JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING',
JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED',
JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING',
JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED',
JOB_STATE_FAILED = 'JOB_STATE_FAILED',
JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED',
}
export interface UploadVideoResponse {
job_id: string
did: string
cid: string
state: JobState
}
@@ -126,7 +126,7 @@ export function useModerationCauseDescription(
}
}
if (def.identifier === 'porn' || def.identifier === 'sexual') {
strings.name = 'Adult Content'
strings.name = _(msg`Adult Content`)
}
return {
+1
View File
@@ -38,6 +38,7 @@ export type CommonNavigatorParams = {
PreferencesThreads: undefined
PreferencesExternalEmbeds: undefined
AccessibilitySettings: undefined
AppearanceSettings: undefined
Search: {q?: string}
Hashtag: {tag: string; author?: string}
MessagesConversation: {conversation: string; embed?: string}
+6
View File
@@ -215,6 +215,12 @@ export type LogEvents = {
'profile:header:suggestedFollowsCard:press': {}
'debug:followingPrefs': {
followingShowRepliesFromPref: 'all' | 'following' | 'off'
followingRepliesMinLikePref: number
}
'debug:followingDisplayed': {}
'test:all:always': {}
'test:all:sometimes': {}
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
+1
View File
@@ -13,5 +13,6 @@ export type Gate =
| 'suggested_feeds_interstitial'
| 'suggested_follows_interstitial'
| 'ungroup_follow_backs'
| 'video_debug'
| 'videos'
| 'small_avi_thumb'
+1 -3
View File
@@ -1,5 +1,4 @@
import {Platform} from 'react-native'
import {isReducedMotion} from 'react-native-reanimated'
import {getLocales} from 'expo-localization'
import {fixLegacyLanguageCode} from '#/locale/helpers'
@@ -15,11 +14,10 @@ export const isMobileWeb =
isWeb &&
// @ts-ignore we know window exists -prf
global.window.matchMedia(isMobileWebMediaQuery)?.matches
export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent)
export const deviceLocales = dedupArray(
getLocales?.()
.map?.(locale => fixLegacyLanguageCode(locale.languageCode))
.filter(code => typeof code === 'string'),
) as string[]
export const prefersReducedMotion = isReducedMotion()
+1
View File
@@ -32,6 +32,7 @@ export const router = new Router({
PreferencesThreads: '/settings/threads',
PreferencesExternalEmbeds: '/settings/external-embeds',
AccessibilitySettings: '/settings/accessibility',
AppearanceSettings: '/settings/appearance',
SavedFeeds: '/settings/saved-feeds',
Support: '/support',
PrivacyPolicy: '/support/privacy',
@@ -387,9 +387,6 @@ export function MessagesList({
renderItem={renderItem}
keyExtractor={keyExtractor}
disableFullWindowScroll={true}
// Prevents wrong position in Firefox when sending a message
// as well as scroll getting stuck on Chome when scrolling upwards.
disableContainStyle={true}
disableVirtualization={true}
style={animatedListStyle}
// The extra two items account for the header and the footer components
+16 -4
View File
@@ -240,7 +240,10 @@ export function ModerationScreenInner({
)}
</Button>
<Divider />
<Link testID="moderationlistsBtn" to="/moderation/modlists">
<Link
label={_(msg`View your moderation lists`)}
testID="moderationlistsBtn"
to="/moderation/modlists">
{state => (
<SubItem
title={_(msg`Moderation lists`)}
@@ -252,7 +255,10 @@ export function ModerationScreenInner({
)}
</Link>
<Divider />
<Link testID="mutedAccountsBtn" to="/moderation/muted-accounts">
<Link
label={_(msg`View your muted accounts`)}
testID="mutedAccountsBtn"
to="/moderation/muted-accounts">
{state => (
<SubItem
title={_(msg`Muted accounts`)}
@@ -264,7 +270,10 @@ export function ModerationScreenInner({
)}
</Link>
<Divider />
<Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts">
<Link
label={_(msg`View your blocked accounts`)}
testID="blockedAccountsBtn"
to="/moderation/blocked-accounts">
{state => (
<SubItem
title={_(msg`Blocked accounts`)}
@@ -356,6 +365,7 @@ export function ModerationScreenInner({
<Trans>
Adult content can only be enabled via the Web at{' '}
<InlineLinkText
label={_(msg`The Bluesky web application`)}
to=""
onPress={evt => {
evt.preventDefault()
@@ -569,7 +579,9 @@ function PwiOptOut() {
</Trans>
</Text>
<InlineLinkText to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
<InlineLinkText
label={_(msg`Learn more about what is public on Bluesky.`)}
to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
<Trans>Learn more about what is public on Bluesky.</Trans>
</InlineLinkText>
</View>
+1
View File
@@ -79,6 +79,7 @@ export const ProfileFeedSection = React.forwardRef<
headerOffset={headerHeight}
renderEndOfFeed={ProfileEndOfFeed}
ignoreFilterFor={ignoreFilterFor}
outsideHeaderOffset={headerHeight}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
+135
View File
@@ -0,0 +1,135 @@
import React, {useCallback} from 'react'
import {View} from 'react-native'
import Animated, {
FadeInDown,
FadeOutDown,
LayoutAnimationConfig,
} from 'react-native-reanimated'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {s} from '#/lib/styles'
import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
import {ScrollView} from '#/view/com/util/Views'
import {atoms as a, native, useTheme} from '#/alf'
import * as ToggleButton from '#/components/forms/ToggleButton'
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
import {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'>
export function AppearanceSettingsScreen({}: Props) {
const {_} = useLingui()
const t = useTheme()
const {isTabletOrMobile} = useWebMediaQueries()
const {colorMode, darkTheme} = useThemePrefs()
const {setColorMode, setDarkTheme} = useSetThemePrefs()
const onChangeAppearance = useCallback(
(keys: string[]) => {
const appearance = keys.find(key => key !== colorMode) as
| 'system'
| 'light'
| 'dark'
| undefined
if (!appearance) return
setColorMode(appearance)
},
[setColorMode, colorMode],
)
const onChangeDarkTheme = useCallback(
(keys: string[]) => {
const theme = keys.find(key => key !== darkTheme) as
| 'dim'
| 'dark'
| undefined
if (!theme) return
setDarkTheme(theme)
},
[setDarkTheme, darkTheme],
)
return (
<LayoutAnimationConfig skipExiting skipEntering>
<View testID="preferencesThreadsScreen" style={s.hContentRegion}>
<ScrollView
// @ts-ignore web only -prf
dataSet={{'stable-gutters': 1}}
contentContainerStyle={{paddingBottom: 75}}>
<SimpleViewHeader
showBackButton={isTabletOrMobile}
style={[t.atoms.border_contrast_medium, a.border_b]}>
<View style={a.flex_1}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Appearance</Trans>
</Text>
</View>
</SimpleViewHeader>
<View style={[a.p_xl, a.gap_lg]}>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<PhoneIcon style={t.atoms.text} />
<Text style={a.text_md}>
<Trans>Mode</Trans>
</Text>
</View>
<ToggleButton.Group
label={_(msg`Dark mode`)}
values={[colorMode]}
onChange={onChangeAppearance}>
<ToggleButton.Button label={_(msg`System`)} name="system">
<ToggleButton.ButtonText>
<Trans>System</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button label={_(msg`Light`)} name="light">
<ToggleButton.ButtonText>
<Trans>Light</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button label={_(msg`Dark`)} name="dark">
<ToggleButton.ButtonText>
<Trans>Dark</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
</ToggleButton.Group>
{colorMode !== 'light' && (
<Animated.View
entering={native(FadeInDown)}
exiting={native(FadeOutDown)}
style={[a.mt_md, a.gap_lg]}>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<MoonIcon style={t.atoms.text} />
<Text style={a.text_md}>
<Trans>Dark theme</Trans>
</Text>
</View>
<ToggleButton.Group
label={_(msg`Dark theme`)}
values={[darkTheme ?? 'dim']}
onChange={onChangeDarkTheme}>
<ToggleButton.Button label={_(msg`Dim`)} name="dim">
<ToggleButton.ButtonText>
<Trans>Dim</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button label={_(msg`Dark`)} name="dark">
<ToggleButton.ButtonText>
<Trans>Dark</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
</ToggleButton.Group>
</Animated.View>
)}
</View>
</ScrollView>
</View>
</LayoutAnimationConfig>
)
}
+8 -2
View File
@@ -45,14 +45,20 @@ export const Policies = ({
const els = []
if (tos) {
els.push(
<InlineLinkText key="tos" to={tos}>
<InlineLinkText
label={_(msg`Read the Bluesky Terms of Service`)}
key="tos"
to={tos}>
{_(msg`Terms of Service`)}
</InlineLinkText>,
)
}
if (pp) {
els.push(
<InlineLinkText key="pp" to={pp}>
<InlineLinkText
label={_(msg`Read the Bluesky Privacy Policy`)}
key="pp"
to={pp}>
{_(msg`Privacy Policy`)}
</InlineLinkText>,
)
+1
View File
@@ -166,6 +166,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
<Text style={[t.atoms.text, !gtMobile && a.text_md]}>
<Trans>Having trouble?</Trans>{' '}
<InlineLinkText
label={_(msg`Contact support`)}
to={FEEDBACK_FORM_URL({email: state.email})}
style={[!gtMobile && a.text_md]}>
<Trans>Contact support</Trans>
+2 -2
View File
@@ -8,8 +8,8 @@ import {useA11y} from '#/state/a11y'
import {DISCOVER_FEED_URI} from 'lib/constants'
import {
useGetPopularFeedsQuery,
usePopularFeedsSearch,
useSavedFeeds,
useSearchPopularFeedsQuery,
} from 'state/queries/feed'
import {SearchInput} from 'view/com/util/forms/SearchInput'
import {List} from 'view/com/util/List'
@@ -59,7 +59,7 @@ export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
: undefined
const {data: searchedFeeds, isFetching: isFetchingSearchedFeeds} =
useSearchPopularFeedsQuery({q: throttledQuery})
usePopularFeedsSearch({query: throttledQuery})
const isLoading =
!isFetchedSavedFeeds || isLoadingPopularFeeds || isFetchingSearchedFeeds
+2 -2
View File
@@ -1,8 +1,8 @@
import React from 'react'
import {AccessibilityInfo} from 'react-native'
import {isReducedMotion} from 'react-native-reanimated'
import {isWeb} from '#/platform/detection'
import {PlatformInfo} from '../../modules/expo-bluesky-swiss-army'
const Context = React.createContext({
reduceMotionEnabled: false,
@@ -15,7 +15,7 @@ export function useA11y() {
export function Provider({children}: React.PropsWithChildren<{}>) {
const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() =>
isReducedMotion(),
PlatformInfo.getIsReducedMotionEnabled(),
)
const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false)
+1 -1
View File
@@ -123,7 +123,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
toString({
item: postItem.uri,
event: 'app.bsky.feed.defs#interactionSeen',
feedContext: postItem.feedContext,
feedContext: slice.feedContext,
}),
)
sendToFeed()
+3 -2
View File
@@ -1,4 +1,5 @@
import React from 'react'
import * as persisted from '#/state/persisted'
type StateContext = persisted.Schema['invites']
@@ -35,8 +36,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('invites'))
return persisted.onUpdate('invites', nextInvites => {
setState(nextInvites)
})
}, [setState])
-67
View File
@@ -1,67 +0,0 @@
import type {LegacySchema} from '#/state/persisted/legacy'
export const ALICE_DID = 'did:plc:ALICE_DID'
export const BOB_DID = 'did:plc:BOB_DID'
export const LEGACY_DATA_DUMP: LegacySchema = {
session: {
data: {
service: 'https://bsky.social/',
did: ALICE_DID,
},
accounts: [
{
service: 'https://bsky.social',
did: ALICE_DID,
refreshJwt: 'refreshJwt',
accessJwt: 'accessJwt',
handle: 'alice.test',
email: 'alice@bsky.test',
displayName: 'Alice',
aviUrl: 'avi',
emailConfirmed: true,
},
{
service: 'https://bsky.social',
did: BOB_DID,
refreshJwt: 'refreshJwt',
accessJwt: 'accessJwt',
handle: 'bob.test',
email: 'bob@bsky.test',
displayName: 'Bob',
aviUrl: 'avi',
emailConfirmed: true,
},
],
},
me: {
did: ALICE_DID,
handle: 'alice.test',
displayName: 'Alice',
description: '',
avatar: 'avi',
},
onboarding: {step: 'Home'},
shell: {colorMode: 'system'},
preferences: {
primaryLanguage: 'en',
contentLanguages: ['en'],
postLanguage: 'en',
postLanguageHistory: ['en', 'en', 'ja', 'pt', 'de', 'en'],
contentLabels: {
nsfw: 'warn',
nudity: 'warn',
suggestive: 'warn',
gore: 'warn',
hate: 'hide',
spam: 'hide',
impersonation: 'warn',
},
savedFeeds: ['feed_a', 'feed_b', 'feed_c'],
pinnedFeeds: ['feed_a', 'feed_b'],
requireAltTextEnabled: false,
},
invitedUsers: {seenDids: [], copiedInvites: []},
mutedThreads: {uris: []},
reminders: {},
}
@@ -1,49 +0,0 @@
import {jest, expect, test, afterEach} from '@jest/globals'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {defaults} from '#/state/persisted/schema'
import {migrate} from '#/state/persisted/legacy'
import * as store from '#/state/persisted/store'
import * as persisted from '#/state/persisted'
const write = jest.mocked(store.write)
const read = jest.mocked(store.read)
jest.mock('#/logger')
jest.mock('#/state/persisted/legacy', () => ({
migrate: jest.fn(),
}))
jest.mock('#/state/persisted/store', () => ({
write: jest.fn(),
read: jest.fn(),
}))
afterEach(() => {
jest.useFakeTimers()
jest.clearAllMocks()
AsyncStorage.clear()
})
test('init: fresh install, no migration', async () => {
await persisted.init()
expect(migrate).toHaveBeenCalledTimes(1)
expect(read).toHaveBeenCalledTimes(1)
expect(write).toHaveBeenCalledWith(defaults)
// default value
expect(persisted.get('colorMode')).toBe('system')
})
test('init: fresh install, migration ran', async () => {
read.mockResolvedValueOnce(defaults)
await persisted.init()
expect(migrate).toHaveBeenCalledTimes(1)
expect(read).toHaveBeenCalledTimes(1)
expect(write).not.toHaveBeenCalled()
// default value
expect(persisted.get('colorMode')).toBe('system')
})
@@ -1,93 +0,0 @@
import {jest, expect, test, afterEach} from '@jest/globals'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {defaults, schema} from '#/state/persisted/schema'
import {transform, migrate} from '#/state/persisted/legacy'
import * as store from '#/state/persisted/store'
import {logger} from '#/logger'
import * as fixtures from '#/state/persisted/__tests__/fixtures'
const write = jest.mocked(store.write)
const read = jest.mocked(store.read)
jest.mock('#/logger')
jest.mock('#/state/persisted/store', () => ({
write: jest.fn(),
read: jest.fn(),
}))
afterEach(() => {
jest.clearAllMocks()
AsyncStorage.clear()
})
test('migrate: fresh install', async () => {
await migrate()
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(read).toHaveBeenCalledTimes(1)
expect(logger.debug).toHaveBeenCalledWith(
'persisted state: no migration needed',
)
})
test('migrate: fresh install, existing new storage', async () => {
read.mockResolvedValueOnce(defaults)
await migrate()
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(read).toHaveBeenCalledTimes(1)
expect(logger.debug).toHaveBeenCalledWith(
'persisted state: no migration needed',
)
})
test('migrate: fresh install, AsyncStorage error', async () => {
const prevGetItem = AsyncStorage.getItem
const error = new Error('test error')
AsyncStorage.getItem = jest.fn(() => {
throw error
})
await migrate()
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(logger.error).toHaveBeenCalledWith(error, {
message: 'persisted state: error migrating legacy storage',
})
AsyncStorage.getItem = prevGetItem
})
test('migrate: has legacy data', async () => {
await AsyncStorage.setItem('root', JSON.stringify(fixtures.LEGACY_DATA_DUMP))
await migrate()
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
expect(logger.debug).toHaveBeenCalledWith(
'persisted state: migrated legacy storage',
)
})
test('migrate: has legacy data, fails validation', async () => {
const legacy = fixtures.LEGACY_DATA_DUMP
// @ts-ignore
legacy.shell.colorMode = 'invalid'
await AsyncStorage.setItem('root', JSON.stringify(legacy))
await migrate()
const transformed = transform(legacy)
const validate = schema.safeParse(transformed)
expect(write).not.toHaveBeenCalled()
expect(logger.error).toHaveBeenCalledWith(
'persisted state: legacy data failed validation',
// @ts-ignore
{message: validate.error},
)
})
@@ -1,21 +0,0 @@
import {expect, test} from '@jest/globals'
import {transform} from '#/state/persisted/legacy'
import {defaults, schema} from '#/state/persisted/schema'
import * as fixtures from '#/state/persisted/__tests__/fixtures'
test('defaults', () => {
expect(() => schema.parse(defaults)).not.toThrow()
})
test('transform', () => {
const data = transform({})
expect(() => schema.parse(data)).not.toThrow()
})
test('transform: legacy fixture', () => {
const data = transform(fixtures.LEGACY_DATA_DUMP)
expect(() => schema.parse(data)).not.toThrow()
expect(data.session.currentAccount?.did).toEqual(fixtures.ALICE_DID)
expect(data.session.accounts.length).toEqual(2)
})
+57 -68
View File
@@ -1,97 +1,86 @@
import EventEmitter from 'eventemitter3'
import AsyncStorage from '@react-native-async-storage/async-storage'
import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger'
import {migrate} from '#/state/persisted/legacy'
import {defaults, Schema} from '#/state/persisted/schema'
import * as store from '#/state/persisted/store'
import {
defaults,
Schema,
tryParse,
tryStringify,
} from '#/state/persisted/schema'
import {PersistedApi} from './types'
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema'
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
const UPDATE_EVENT = 'BSKY_UPDATE'
const BSKY_STORAGE = 'BSKY_STORAGE'
let _state: Schema = defaults
const _emitter = new EventEmitter()
/**
* Initializes and returns persisted data state, so that it can be passed to
* the Provider.
*/
export async function init() {
logger.debug('persisted state: initializing')
broadcast.onmessage = onBroadcastMessage
try {
await migrate() // migrate old store
const stored = await store.read() // check for new store
if (!stored) {
logger.debug('persisted state: initializing default storage')
await store.write(defaults) // opt: init new store
}
_state = stored || defaults // return new store
logger.debug('persisted state: initialized')
} catch (e) {
logger.error('persisted state: failed to load root state from storage', {
message: e,
})
// AsyncStorage failure, but we can still continue in memory
return defaults
const stored = await readFromStorage()
if (stored) {
_state = stored
}
}
init satisfies PersistedApi['init']
export function get<K extends keyof Schema>(key: K): Schema[K] {
return _state[key]
}
get satisfies PersistedApi['get']
export async function write<K extends keyof Schema>(
key: K,
value: Schema[K],
): Promise<void> {
_state = {
..._state,
[key]: value,
}
await writeToStorage(_state)
}
write satisfies PersistedApi['write']
export function onUpdate<K extends keyof Schema>(
_key: K,
_cb: (v: Schema[K]) => void,
): () => void {
return () => {}
}
onUpdate satisfies PersistedApi['onUpdate']
export async function clearStorage() {
try {
_state[key] = value
await store.write(_state)
// must happen on next tick, otherwise the tab will read stale storage data
setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0)
logger.debug(`persisted state: wrote root state to storage`, {
updatedKey: key,
})
} catch (e) {
logger.error(`persisted state: failed writing root state to storage`, {
message: e,
})
await AsyncStorage.removeItem(BSKY_STORAGE)
} catch (e: any) {
logger.error(`persisted store: failed to clear`, {message: e.toString()})
}
}
clearStorage satisfies PersistedApi['clearStorage']
export function onUpdate(cb: () => void): () => void {
_emitter.addListener('update', cb)
return () => _emitter.removeListener('update', cb)
}
async function onBroadcastMessage({data}: MessageEvent) {
// validate event
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
async function writeToStorage(value: Schema) {
const rawData = tryStringify(value)
if (rawData) {
try {
// read next state, possibly updated by another tab
const next = await store.read()
if (next) {
logger.debug(`persisted state: handling update from broadcast channel`)
_state = next
_emitter.emit('update')
} else {
logger.error(
`persisted state: handled update update from broadcast channel, but found no data`,
)
}
await AsyncStorage.setItem(BSKY_STORAGE, rawData)
} catch (e) {
logger.error(
`persisted state: failed handling update from broadcast channel`,
{
message: e,
},
)
logger.error(`persisted state: failed writing root state to storage`, {
message: e,
})
}
}
}
async function readFromStorage(): Promise<Schema | undefined> {
let rawData: string | null = null
try {
rawData = await AsyncStorage.getItem(BSKY_STORAGE)
} catch (e) {
logger.error(`persisted state: failed reading root state from storage`, {
message: e,
})
}
if (rawData) {
return tryParse(rawData)
}
}
+148
View File
@@ -0,0 +1,148 @@
import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger'
import {
defaults,
Schema,
tryParse,
tryStringify,
} from '#/state/persisted/schema'
import {PersistedApi} from './types'
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema'
const BSKY_STORAGE = 'BSKY_STORAGE'
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
const UPDATE_EVENT = 'BSKY_UPDATE'
let _state: Schema = defaults
const _emitter = new EventEmitter()
export async function init() {
broadcast.onmessage = onBroadcastMessage
const stored = readFromStorage()
if (stored) {
_state = stored
}
}
init satisfies PersistedApi['init']
export function get<K extends keyof Schema>(key: K): Schema[K] {
return _state[key]
}
get satisfies PersistedApi['get']
export async function write<K extends keyof Schema>(
key: K,
value: Schema[K],
): Promise<void> {
const next = readFromStorage()
if (next) {
// The storage could have been updated by a different tab before this tab is notified.
// Make sure this write is applied on top of the latest data in the storage as long as it's valid.
_state = next
// Don't fire the update listeners yet to avoid a loop.
// If there was a change, we'll receive the broadcast event soon enough which will do that.
}
try {
if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) {
// Fast path for updates that are guaranteed to be noops.
// This is good mostly because it avoids useless broadcasts to other tabs.
return
}
} catch (e) {
// Ignore and go through the normal path.
}
_state = {
..._state,
[key]: value,
}
writeToStorage(_state)
broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading
}
write satisfies PersistedApi['write']
export function onUpdate<K extends keyof Schema>(
key: K,
cb: (v: Schema[K]) => void,
): () => void {
const listener = () => cb(get(key))
_emitter.addListener('update', listener) // Backcompat while upgrading
_emitter.addListener('update:' + key, listener)
return () => {
_emitter.removeListener('update', listener) // Backcompat while upgrading
_emitter.removeListener('update:' + key, listener)
}
}
onUpdate satisfies PersistedApi['onUpdate']
export async function clearStorage() {
try {
localStorage.removeItem(BSKY_STORAGE)
} catch (e: any) {
// Expected on the web in private mode.
}
}
clearStorage satisfies PersistedApi['clearStorage']
async function onBroadcastMessage({data}: MessageEvent) {
if (
typeof data === 'object' &&
(data.event === UPDATE_EVENT || // Backcompat while upgrading
data.event?.type === UPDATE_EVENT)
) {
// read next state, possibly updated by another tab
const next = readFromStorage()
if (next === _state) {
return
}
if (next) {
_state = next
if (typeof data.event.key === 'string') {
_emitter.emit('update:' + data.event.key)
} else {
_emitter.emit('update') // Backcompat while upgrading
}
} else {
logger.error(
`persisted state: handled update update from broadcast channel, but found no data`,
)
}
}
}
function writeToStorage(value: Schema) {
const rawData = tryStringify(value)
if (rawData) {
try {
localStorage.setItem(BSKY_STORAGE, rawData)
} catch (e) {
// Expected on the web in private mode.
}
}
}
let lastRawData: string | undefined
let lastResult: Schema | undefined
function readFromStorage(): Schema | undefined {
let rawData: string | null = null
try {
rawData = localStorage.getItem(BSKY_STORAGE)
} catch (e) {
// Expected on the web in private mode.
}
if (rawData) {
if (rawData === lastRawData) {
return lastResult
} else {
const result = tryParse(rawData)
lastRawData = rawData
lastResult = result
return result
}
}
}
-167
View File
@@ -1,167 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {logger} from '#/logger'
import {defaults, Schema, schema} from '#/state/persisted/schema'
import {read, write} from '#/state/persisted/store'
/**
* The shape of the serialized data from our legacy Mobx store.
*/
export type LegacySchema = {
shell: {
colorMode: 'system' | 'light' | 'dark'
}
session: {
data: {
service: string
did: `did:plc:${string}`
} | null
accounts: {
service: string
did: `did:plc:${string}`
refreshJwt: string
accessJwt: string
handle: string
email: string
displayName: string
aviUrl: string
emailConfirmed: boolean
}[]
}
me: {
did: `did:plc:${string}`
handle: string
displayName: string
description: string
avatar: string
}
onboarding: {
step: string
}
preferences: {
primaryLanguage: string
contentLanguages: string[]
postLanguage: string
postLanguageHistory: string[]
contentLabels: {
nsfw: string
nudity: string
suggestive: string
gore: string
hate: string
spam: string
impersonation: string
}
savedFeeds: string[]
pinnedFeeds: string[]
requireAltTextEnabled: boolean
}
invitedUsers: {
seenDids: string[]
copiedInvites: string[]
}
mutedThreads: {uris: string[]}
reminders: {lastEmailConfirm?: string}
}
const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root'
export function transform(legacy: Partial<LegacySchema>): Schema {
return {
colorMode: legacy.shell?.colorMode || defaults.colorMode,
darkTheme: defaults.darkTheme,
session: {
accounts: legacy.session?.accounts || defaults.session.accounts,
currentAccount:
legacy.session?.accounts?.find(
a => a.did === legacy.session?.data?.did,
) || defaults.session.currentAccount,
},
reminders: {
lastEmailConfirm:
legacy.reminders?.lastEmailConfirm ||
defaults.reminders.lastEmailConfirm,
},
languagePrefs: {
primaryLanguage:
legacy.preferences?.primaryLanguage ||
defaults.languagePrefs.primaryLanguage,
contentLanguages:
legacy.preferences?.contentLanguages ||
defaults.languagePrefs.contentLanguages,
postLanguage:
legacy.preferences?.postLanguage || defaults.languagePrefs.postLanguage,
postLanguageHistory:
legacy.preferences?.postLanguageHistory ||
defaults.languagePrefs.postLanguageHistory,
appLanguage:
legacy.preferences?.primaryLanguage ||
defaults.languagePrefs.appLanguage,
},
requireAltTextEnabled:
legacy.preferences?.requireAltTextEnabled ||
defaults.requireAltTextEnabled,
mutedThreads: legacy.mutedThreads?.uris || defaults.mutedThreads,
invites: {
copiedInvites:
legacy.invitedUsers?.copiedInvites || defaults.invites.copiedInvites,
},
onboarding: {
step: legacy.onboarding?.step || defaults.onboarding.step,
},
hiddenPosts: defaults.hiddenPosts,
externalEmbeds: defaults.externalEmbeds,
lastSelectedHomeFeed: defaults.lastSelectedHomeFeed,
pdsAddressHistory: defaults.pdsAddressHistory,
disableHaptics: defaults.disableHaptics,
}
}
/**
* Migrates legacy persisted state to new store if new store doesn't exist in
* local storage AND old storage exists.
*/
export async function migrate() {
logger.debug('persisted state: check need to migrate')
try {
const rawLegacyData = await AsyncStorage.getItem(
DEPRECATED_ROOT_STATE_STORAGE_KEY,
)
const newData = await read()
const alreadyMigrated = Boolean(newData)
if (!alreadyMigrated && rawLegacyData) {
logger.debug('persisted state: migrating legacy storage')
const legacyData = JSON.parse(rawLegacyData)
const newData = transform(legacyData)
const validate = schema.safeParse(newData)
if (validate.success) {
await write(newData)
logger.debug('persisted state: migrated legacy storage')
} else {
logger.error('persisted state: legacy data failed validation', {
message: validate.error,
})
}
} else {
logger.debug('persisted state: no migration needed')
}
} catch (e: any) {
logger.error(e, {
message: 'persisted state: error migrating legacy storage',
})
}
}
export async function clearLegacyStorage() {
try {
await AsyncStorage.removeItem(DEPRECATED_ROOT_STATE_STORAGE_KEY)
} catch (e: any) {
logger.error(`persisted legacy store: failed to clear`, {
message: e.toString(),
})
}
}
+47 -3
View File
@@ -1,6 +1,8 @@
import {z} from 'zod'
import {deviceLocales, prefersReducedMotion} from '#/platform/detection'
import {logger} from '#/logger'
import {deviceLocales} from '#/platform/detection'
import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army'
const externalEmbedOptions = ['show', 'hide'] as const
@@ -42,7 +44,7 @@ const currentAccountSchema = accountSchema.extend({
})
export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
export const schema = z.object({
const schema = z.object({
colorMode: z.enum(['system', 'light', 'dark']),
darkTheme: z.enum(['dim', 'dark']).optional(),
session: z.object({
@@ -89,6 +91,7 @@ export const schema = z.object({
disableAutoplay: z.boolean().optional(),
kawaii: z.boolean().optional(),
hasCheckedForStarterPack: z.boolean().optional(),
subtitlesEnabled: z.boolean().optional(),
/** @deprecated */
mutedThreads: z.array(z.string()),
})
@@ -128,7 +131,48 @@ export const defaults: Schema = {
lastSelectedHomeFeed: undefined,
pdsAddressHistory: [],
disableHaptics: false,
disableAutoplay: prefersReducedMotion,
disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
kawaii: false,
hasCheckedForStarterPack: false,
subtitlesEnabled: true,
}
export function tryParse(rawData: string): Schema | undefined {
let objData
try {
objData = JSON.parse(rawData)
} catch (e) {
logger.error('persisted state: failed to parse root state from storage', {
message: e,
})
}
if (!objData) {
return undefined
}
const parsed = schema.safeParse(objData)
if (parsed.success) {
return objData
} else {
const errors =
parsed.error?.errors?.map(e => ({
code: e.code,
// @ts-ignore exists on some types
expected: e?.expected,
path: e.path?.join('.'),
})) || []
logger.error(`persisted store: data failed validation on read`, {errors})
return undefined
}
}
export function tryStringify(value: Schema): string | undefined {
try {
schema.parse(value)
return JSON.stringify(value)
} catch (e) {
logger.error(`persisted state: failed stringifying root state`, {
message: e,
})
return undefined
}
}
-44
View File
@@ -1,44 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {logger} from '#/logger'
import {Schema, schema} from '#/state/persisted/schema'
const BSKY_STORAGE = 'BSKY_STORAGE'
export async function write(value: Schema) {
schema.parse(value)
await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value))
}
export async function read(): Promise<Schema | undefined> {
const rawData = await AsyncStorage.getItem(BSKY_STORAGE)
const objData = rawData ? JSON.parse(rawData) : undefined
// new user
if (!objData) return undefined
// existing user, validate
const parsed = schema.safeParse(objData)
if (parsed.success) {
return objData
} else {
const errors =
parsed.error?.errors?.map(e => ({
code: e.code,
// @ts-ignore exists on some types
expected: e?.expected,
path: e.path?.join('.'),
})) || []
logger.error(`persisted store: data failed validation on read`, {errors})
return undefined
}
}
export async function clear() {
try {
await AsyncStorage.removeItem(BSKY_STORAGE)
} catch (e: any) {
logger.error(`persisted store: failed to clear`, {message: e.toString()})
}
}
+12
View File
@@ -0,0 +1,12 @@
import type {Schema} from './schema'
export type PersistedApi = {
init(): Promise<void>
get<K extends keyof Schema>(key: K): Schema[K]
write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void>
onUpdate<K extends keyof Schema>(
key: K,
cb: (v: Schema[K]) => void,
): () => void
clearStorage: () => Promise<void>
}
+6 -3
View File
@@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('requireAltTextEnabled'))
})
return persisted.onUpdate(
'requireAltTextEnabled',
nextRequireAltTextEnabled => {
setState(nextRequireAltTextEnabled)
},
)
}, [setStateWrapped])
return (

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