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) ...
@@ -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 |
@@ -67,6 +67,13 @@ func run(args []string) {
|
|||||||
Required: false,
|
Required: false,
|
||||||
EnvVars: []string{"DEBUG"},
|
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"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -48,6 +49,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
appviewHost := cctx.String("appview-host")
|
appviewHost := cctx.String("appview-host")
|
||||||
ogcardHost := cctx.String("ogcard-host")
|
ogcardHost := cctx.String("ogcard-host")
|
||||||
linkHost := cctx.String("link-host")
|
linkHost := cctx.String("link-host")
|
||||||
|
basicAuthPassword := cctx.String("basic-auth-password")
|
||||||
|
|
||||||
// Echo
|
// Echo
|
||||||
e := echo.New()
|
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.
|
// redirect trailing slash to non-trailing slash.
|
||||||
// all of our current endpoints have no trailing slash.
|
// all of our current endpoints have no trailing slash.
|
||||||
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
|
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/threads", server.WebGeneric)
|
||||||
e.GET("/settings/external-embeds", server.WebGeneric)
|
e.GET("/settings/external-embeds", server.WebGeneric)
|
||||||
e.GET("/settings/accessibility", server.WebGeneric)
|
e.GET("/settings/accessibility", server.WebGeneric)
|
||||||
|
e.GET("/settings/appearance", server.WebGeneric)
|
||||||
e.GET("/sys/debug", server.WebGeneric)
|
e.GET("/sys/debug", server.WebGeneric)
|
||||||
e.GET("/sys/debug-mod", server.WebGeneric)
|
e.GET("/sys/debug-mod", server.WebGeneric)
|
||||||
e.GET("/sys/log", server.WebGeneric)
|
e.GET("/sys/log", server.WebGeneric)
|
||||||
|
|||||||
@@ -95,3 +95,16 @@ jest.mock('expo-application', () => ({
|
|||||||
nativeApplicationVersion: '1.0.0',
|
nativeApplicationVersion: '1.0.0',
|
||||||
nativeBuildVersion: '1',
|
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"],
|
"platforms": ["ios", "tvos", "android", "web"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"]
|
"modules": [
|
||||||
|
"ExpoBlueskySharedPrefsModule",
|
||||||
|
"ExpoBlueskyReferrerModule",
|
||||||
|
"ExpoBlueskyVisibilityViewModule",
|
||||||
|
"ExpoPlatformInfoModule"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"modules": [
|
"modules": [
|
||||||
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
|
"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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import * as PlatformInfo from './src/PlatformInfo'
|
||||||
import * as Referrer from './src/Referrer'
|
import * as Referrer from './src/Referrer'
|
||||||
import * as SharedPrefs from './src/SharedPrefs'
|
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
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.89.0",
|
"version": "1.90.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.12.25",
|
"@atproto/api": "0.12.29",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
@@ -143,6 +143,7 @@
|
|||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
"hls.js": "^1.5.11",
|
||||||
"js-sha256": "^0.9.0",
|
"js-sha256": "^0.9.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lande": "^1.0.10",
|
"lande": "^1.0.10",
|
||||||
@@ -269,7 +270,7 @@
|
|||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
"react-test-renderer": "18.2.0",
|
"react-test-renderer": "18.2.0",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.5.4",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
"webpack": "^5.75.0",
|
"webpack": "^5.75.0",
|
||||||
"webpack-bundle-analyzer": "^4.10.1",
|
"webpack-bundle-analyzer": "^4.10.1",
|
||||||
|
|||||||
@@ -12,3 +12,15 @@ index bb74e80..0aa0202 100644
|
|||||||
|
|
||||||
Map<String, Object> constants = new HashMap<>(3);
|
Map<String, Object> constants = new HashMap<>(3);
|
||||||
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
|
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() {
|
||||||
|
|||||||
@@ -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;
|
const { layout, entering, exiting, sharedTransitionTag } = this.props;
|
||||||
if (
|
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';
|
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ import {
|
|||||||
} from '#/lib/statsig/statsig'
|
} from '#/lib/statsig/statsig'
|
||||||
import {s} from '#/lib/styles'
|
import {s} from '#/lib/styles'
|
||||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||||
|
import I18nProvider from '#/locale/i18nProvider'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {Provider as A11yProvider} from '#/state/a11y'
|
import {Provider as A11yProvider} from '#/state/a11y'
|
||||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||||
|
import {listenSessionDropped} from '#/state/events'
|
||||||
import {Provider as InvitesStateProvider} from '#/state/invites'
|
import {Provider as InvitesStateProvider} from '#/state/invites'
|
||||||
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
||||||
import {MessagesProvider} from '#/state/messages'
|
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 SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
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 * as Toast from '#/view/com/util/Toast'
|
||||||
import {Shell} from '#/view/shell'
|
import {Shell} from '#/view/shell'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
@@ -58,8 +61,6 @@ import {Provider as PortalProvider} from '#/components/Portal'
|
|||||||
import {Splash} from '#/Splash'
|
import {Splash} from '#/Splash'
|
||||||
import {Provider as TourProvider} from '#/tours'
|
import {Provider as TourProvider} from '#/tours'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
import I18nProvider from './locale/i18nProvider'
|
|
||||||
import {listenSessionDropped} from './state/events'
|
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync()
|
SplashScreen.preventAutoHideAsync()
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ function InnerApp() {
|
|||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||||
|
<ActiveVideoProvider>
|
||||||
<RootSiblingParent>
|
<RootSiblingParent>
|
||||||
<React.Fragment
|
<React.Fragment
|
||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
@@ -143,6 +145,7 @@ function InnerApp() {
|
|||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
</RootSiblingParent>
|
</RootSiblingParent>
|
||||||
|
</ActiveVideoProvider>
|
||||||
</Splash>
|
</Splash>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</Alf>
|
</Alf>
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
|||||||
import {QueryProvider} from '#/lib/react-query'
|
import {QueryProvider} from '#/lib/react-query'
|
||||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||||
|
import I18nProvider from '#/locale/i18nProvider'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {Provider as A11yProvider} from '#/state/a11y'
|
import {Provider as A11yProvider} from '#/state/a11y'
|
||||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||||
|
import {listenSessionDropped} from '#/state/events'
|
||||||
import {Provider as InvitesStateProvider} from '#/state/invites'
|
import {Provider as InvitesStateProvider} from '#/state/invites'
|
||||||
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
||||||
import {MessagesProvider} from '#/state/messages'
|
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 ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
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 * as Toast from '#/view/com/util/Toast'
|
||||||
import {ToastContainer} from '#/view/com/util/Toast.web'
|
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||||
import {Shell} from '#/view/shell/index'
|
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 PortalProvider} from '#/components/Portal'
|
||||||
import {Provider as TourProvider} from '#/tours'
|
import {Provider as TourProvider} from '#/tours'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
import I18nProvider from './locale/i18nProvider'
|
|
||||||
import {listenSessionDropped} from './state/events'
|
|
||||||
|
|
||||||
function InnerApp() {
|
function InnerApp() {
|
||||||
const [isReady, setIsReady] = React.useState(false)
|
const [isReady, setIsReady] = React.useState(false)
|
||||||
@@ -92,6 +93,7 @@ function InnerApp() {
|
|||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<RootSiblingParent>
|
<RootSiblingParent>
|
||||||
|
<ActiveVideoProvider>
|
||||||
<React.Fragment
|
<React.Fragment
|
||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
key={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
@@ -125,6 +127,7 @@ function InnerApp() {
|
|||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
</ActiveVideoProvider>
|
||||||
</RootSiblingParent>
|
</RootSiblingParent>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</Alf>
|
</Alf>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import HashtagScreen from '#/screens/Hashtag'
|
|||||||
import {ModerationScreen} from '#/screens/Moderation'
|
import {ModerationScreen} from '#/screens/Moderation'
|
||||||
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
||||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||||
|
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||||
import {
|
import {
|
||||||
StarterPackScreen,
|
StarterPackScreen,
|
||||||
StarterPackScreenShort,
|
StarterPackScreenShort,
|
||||||
@@ -310,6 +311,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
|||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="AppearanceSettings"
|
||||||
|
getComponent={() => AppearanceSettingsScreen}
|
||||||
|
options={{
|
||||||
|
title: title(msg`Appearance Settings`),
|
||||||
|
requireAuth: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Hashtag"
|
name="Hashtag"
|
||||||
getComponent={() => HashtagScreen}
|
getComponent={() => HashtagScreen}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {Platform, StyleSheet} from 'react-native'
|
|||||||
|
|
||||||
import * as tokens from '#/alf/tokens'
|
import * as tokens from '#/alf/tokens'
|
||||||
import {native, web} from '#/alf/util/platform'
|
import {native, web} from '#/alf/util/platform'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
export const atoms = {
|
export const atoms = {
|
||||||
/*
|
/*
|
||||||
@@ -284,19 +283,19 @@ export const atoms = {
|
|||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
},
|
},
|
||||||
border: {
|
border: {
|
||||||
borderWidth: hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
border_t: {
|
border_t: {
|
||||||
borderTopWidth: hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
border_b: {
|
border_b: {
|
||||||
borderBottomWidth: hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
border_l: {
|
border_l: {
|
||||||
borderLeftWidth: hairlineWidth,
|
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
border_r: {
|
border_r: {
|
||||||
borderRightWidth: hairlineWidth,
|
borderRightWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export type ButtonProps = Pick<
|
|||||||
AccessibilityProps &
|
AccessibilityProps &
|
||||||
VariantProps & {
|
VariantProps & {
|
||||||
testID?: string
|
testID?: string
|
||||||
|
/**
|
||||||
|
* For a11y, try to make this descriptive and clear
|
||||||
|
*/
|
||||||
label: string
|
label: string
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
hoverStyle?: StyleProp<ViewStyle>
|
hoverStyle?: StyleProp<ViewStyle>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ type Props = {
|
|||||||
export function Default(props: Props) {
|
export function Default(props: Props) {
|
||||||
const {view} = props
|
const {view} = props
|
||||||
return (
|
return (
|
||||||
<Link label={view.displayName} {...props}>
|
<Link {...props}>
|
||||||
<Outer>
|
<Outer>
|
||||||
<Header>
|
<Header>
|
||||||
<Avatar src={view.avatar} />
|
<Avatar src={view.avatar} />
|
||||||
@@ -58,7 +58,7 @@ export function Link({
|
|||||||
view,
|
view,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: Props & Omit<LinkProps, 'to'>) {
|
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const href = React.useMemo(() => {
|
const href = React.useMemo(() => {
|
||||||
@@ -70,7 +70,11 @@ export function Link({
|
|||||||
}, [view, queryClient])
|
}, [view, queryClient])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InternalLink to={href} style={[a.flex_col]} {...props}>
|
<InternalLink
|
||||||
|
label={view.displayName}
|
||||||
|
to={href}
|
||||||
|
style={[a.flex_col]}
|
||||||
|
{...props}>
|
||||||
{children}
|
{children}
|
||||||
</InternalLink>
|
</InternalLink>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -92,14 +92,16 @@ function getRank(seenPost: SeenPost): string {
|
|||||||
tier = 'a'
|
tier = 'a'
|
||||||
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
||||||
tier = 'b'
|
tier = 'b'
|
||||||
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
} else if (seenPost.feedContext === 'popcluster') {
|
||||||
tier = 'c'
|
tier = 'c'
|
||||||
} else if (seenPost.feedContext?.startsWith('t-')) {
|
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||||
tier = 'd'
|
tier = 'd'
|
||||||
} else if (seenPost.feedContext === 'nettop') {
|
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||||
tier = 'e'
|
tier = 'e'
|
||||||
} else {
|
} else if (seenPost.feedContext === 'nettop') {
|
||||||
tier = 'f'
|
tier = 'f'
|
||||||
|
} else {
|
||||||
|
tier = 'g'
|
||||||
}
|
}
|
||||||
let score = Math.round(
|
let score = Math.round(
|
||||||
Math.log(
|
Math.log(
|
||||||
@@ -190,7 +192,7 @@ export function SuggestedFollows() {
|
|||||||
{profiles.slice(0, maxLength).map(profile => (
|
{profiles.slice(0, maxLength).map(profile => (
|
||||||
<ProfileCard.Link
|
<ProfileCard.Link
|
||||||
key={profile.did}
|
key={profile.did}
|
||||||
did={profile.handle}
|
profile={profile}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
logEvent('feed:interstitial:profileCard:press', {})
|
logEvent('feed:interstitial:profileCard:press', {})
|
||||||
}}
|
}}
|
||||||
@@ -266,7 +268,10 @@ export function SuggestedFollows() {
|
|||||||
a.pt_xs,
|
a.pt_xs,
|
||||||
a.gap_md,
|
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>
|
<Trans>Browse more suggestions</Trans>
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
@@ -396,7 +401,10 @@ export function SuggestedFeeds() {
|
|||||||
a.pt_xs,
|
a.pt_xs,
|
||||||
a.gap_md,
|
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>
|
<Trans>Browse more suggestions</Trans>
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ type BaseLinkProps = Pick<
|
|||||||
> & {
|
> & {
|
||||||
testID?: string
|
testID?: string
|
||||||
|
|
||||||
/**
|
|
||||||
* Label for a11y. Defaults to the href.
|
|
||||||
*/
|
|
||||||
label?: string
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The React Navigation `StackAction` to perform when the link is pressed.
|
* The React Navigation `StackAction` to perform when the link is pressed.
|
||||||
*/
|
*/
|
||||||
@@ -197,7 +192,7 @@ export function useLink({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
|
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
|
* A interactive element that renders as a `<a>` tag on the web. On mobile it
|
||||||
@@ -224,7 +219,6 @@ export function Link({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
label={href}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
style={[a.justify_start, flatten(rest.style)]}
|
style={[a.justify_start, flatten(rest.style)]}
|
||||||
role="link"
|
role="link"
|
||||||
@@ -249,7 +243,8 @@ export function Link({
|
|||||||
|
|
||||||
export type InlineLinkProps = React.PropsWithChildren<
|
export type InlineLinkProps = React.PropsWithChildren<
|
||||||
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
|
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
|
||||||
>
|
> &
|
||||||
|
Pick<ButtonProps, 'label'>
|
||||||
|
|
||||||
export function InlineLinkText({
|
export function InlineLinkText({
|
||||||
children,
|
children,
|
||||||
@@ -291,7 +286,7 @@ export function InlineLinkText({
|
|||||||
<Text
|
<Text
|
||||||
selectable={selectable}
|
selectable={selectable}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
accessibilityLabel={label || href}
|
accessibilityLabel={label}
|
||||||
{...rest}
|
{...rest}
|
||||||
style={[
|
style={[
|
||||||
{color: t.palette.primary_500},
|
{color: t.palette.primary_500},
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ type Props = {
|
|||||||
export function Default(props: Props) {
|
export function Default(props: Props) {
|
||||||
const {view, showPinButton} = props
|
const {view, showPinButton} = props
|
||||||
return (
|
return (
|
||||||
<Link label={view.name} {...props}>
|
<Link {...props}>
|
||||||
<Outer>
|
<Outer>
|
||||||
<Header>
|
<Header>
|
||||||
<Avatar src={view.avatar} />
|
<Avatar src={view.avatar} />
|
||||||
@@ -67,7 +67,7 @@ export function Link({
|
|||||||
view,
|
view,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: Props & Omit<LinkProps, 'to'>) {
|
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const href = React.useMemo(() => {
|
const href = React.useMemo(() => {
|
||||||
@@ -79,7 +79,7 @@ export function Link({
|
|||||||
}, [view, queryClient])
|
}, [view, queryClient])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InternalLink to={href} {...props}>
|
<InternalLink label={view.name} to={href} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</InternalLink>
|
</InternalLink>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -122,8 +122,16 @@ export function ListHeaderDesktop({
|
|||||||
if (!gtTablet) return null
|
if (!gtTablet) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
|
<View
|
||||||
<Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
|
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 ? (
|
{subtitle ? (
|
||||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function Default({
|
|||||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link did={profile.did}>
|
<Link profile={profile}>
|
||||||
<Card
|
<Card
|
||||||
profile={profile}
|
profile={profile}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
@@ -96,16 +96,24 @@ export function Header({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Link({
|
export function Link({
|
||||||
did,
|
profile,
|
||||||
children,
|
children,
|
||||||
style,
|
style,
|
||||||
...rest
|
...rest
|
||||||
}: {did: string} & Omit<LinkProps, 'to'>) {
|
}: {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
} & Omit<LinkProps, 'to' | 'label'>) {
|
||||||
|
const {_} = useLingui()
|
||||||
return (
|
return (
|
||||||
<InternalLink
|
<InternalLink
|
||||||
|
label={_(
|
||||||
|
msg`View ${
|
||||||
|
profile.displayName || sanitizeHandle(profile.handle)
|
||||||
|
}'s profile`,
|
||||||
|
)}
|
||||||
to={{
|
to={{
|
||||||
screen: 'Profile',
|
screen: 'Profile',
|
||||||
params: {name: did},
|
params: {name: profile.did},
|
||||||
}}
|
}}
|
||||||
style={[a.flex_col, style]}
|
style={[a.flex_col, style]}
|
||||||
{...rest}>
|
{...rest}>
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
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 {makeSearchLink} from '#/lib/routes/links'
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
|
useRemoveMutedWordsMutation,
|
||||||
useUpsertMutedWordsMutation,
|
useUpsertMutedWordsMutation,
|
||||||
useRemoveMutedWordMutation,
|
|
||||||
} from '#/state/queries/preferences'
|
} 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 {Loader} from '#/components/Loader'
|
||||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function useTagMenuControl() {
|
export function useTagMenuControl() {
|
||||||
return Dialog.useDialogControl()
|
return Dialog.useDialogControl()
|
||||||
@@ -52,10 +52,10 @@ export function TagMenu({
|
|||||||
reset: resetUpsert,
|
reset: resetUpsert,
|
||||||
} = useUpsertMutedWordsMutation()
|
} = useUpsertMutedWordsMutation()
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeMutedWord,
|
mutateAsync: removeMutedWords,
|
||||||
variables: optimisticRemove,
|
variables: optimisticRemove,
|
||||||
reset: resetRemove,
|
reset: resetRemove,
|
||||||
} = useRemoveMutedWordMutation()
|
} = useRemoveMutedWordsMutation()
|
||||||
const displayTag = '#' + tag
|
const displayTag = '#' + tag
|
||||||
|
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
@@ -65,9 +65,20 @@ export function TagMenu({
|
|||||||
optimisticUpsert?.find(
|
optimisticUpsert?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
@@ -212,13 +223,16 @@ export function TagMenu({
|
|||||||
control.close(() => {
|
control.close(() => {
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
resetUpsert()
|
resetUpsert()
|
||||||
removeMutedWord({
|
removeMutedWords(removeableMuteWords)
|
||||||
value: tag,
|
|
||||||
targets: ['tag'],
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
resetRemove()
|
resetRemove()
|
||||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
upsertMutedWord([
|
||||||
|
{
|
||||||
|
value: tag,
|
||||||
|
targets: ['tag'],
|
||||||
|
actorTarget: 'all',
|
||||||
|
},
|
||||||
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import {msg} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
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 {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||||
|
import {enforceLen} from '#/lib/strings/helpers'
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
|
useRemoveMutedWordsMutation,
|
||||||
useUpsertMutedWordsMutation,
|
useUpsertMutedWordsMutation,
|
||||||
useRemoveMutedWordMutation,
|
|
||||||
} from '#/state/queries/preferences'
|
} 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 {web} from '#/alf'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
|
||||||
@@ -47,8 +47,8 @@ export function TagMenu({
|
|||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
||||||
useUpsertMutedWordsMutation()
|
useUpsertMutedWordsMutation()
|
||||||
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
|
const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
|
||||||
useRemoveMutedWordMutation()
|
useRemoveMutedWordsMutation()
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
(preferences?.moderationPrefs.mutedWords?.find(
|
(preferences?.moderationPrefs.mutedWords?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
@@ -56,10 +56,21 @@ export function TagMenu({
|
|||||||
optimisticUpsert?.find(
|
optimisticUpsert?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
)) &&
|
)) &&
|
||||||
!(optimisticRemove?.value === tag),
|
!optimisticRemove?.find(m => m?.value === tag),
|
||||||
)
|
)
|
||||||
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
|
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(() => {
|
const dropdownItems = React.useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -105,9 +116,11 @@ export function TagMenu({
|
|||||||
: _(msg`Mute ${truncatedTag}`),
|
: _(msg`Mute ${truncatedTag}`),
|
||||||
onPress() {
|
onPress() {
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
removeMutedWord({value: tag, targets: ['tag']})
|
removeMutedWords(removeableMuteWords)
|
||||||
} else {
|
} else {
|
||||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
upsertMutedWord([
|
||||||
|
{value: tag, targets: ['tag'], actorTarget: 'all'},
|
||||||
|
])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
testID: 'tagMenuMute',
|
testID: 'tagMenuMute',
|
||||||
@@ -129,7 +142,8 @@ export function TagMenu({
|
|||||||
tag,
|
tag,
|
||||||
truncatedTag,
|
truncatedTag,
|
||||||
upsertMutedWord,
|
upsertMutedWord,
|
||||||
removeMutedWord,
|
removeMutedWords,
|
||||||
|
removeableMuteWords,
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Keyboard, View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -24,6 +24,7 @@ import * as Dialog from '#/components/Dialog'
|
|||||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
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 {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||||
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
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 * as Prompt from '#/components/Prompt'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
const ONE_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
export function MutedWordsDialog() {
|
export function MutedWordsDialog() {
|
||||||
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
||||||
return (
|
return (
|
||||||
@@ -53,16 +56,32 @@ function MutedWordsInner() {
|
|||||||
} = usePreferencesQuery()
|
} = usePreferencesQuery()
|
||||||
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
||||||
const [field, setField] = React.useState('')
|
const [field, setField] = React.useState('')
|
||||||
const [options, setOptions] = React.useState(['content'])
|
const [targets, setTargets] = React.useState(['content'])
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
|
const [durations, setDurations] = React.useState(['forever'])
|
||||||
|
const [excludeFollowing, setExcludeFollowing] = React.useState(false)
|
||||||
|
|
||||||
const submit = React.useCallback(async () => {
|
const submit = React.useCallback(async () => {
|
||||||
const sanitizedValue = sanitizeMutedWordValue(field)
|
const sanitizedValue = sanitizeMutedWordValue(field)
|
||||||
const targets = ['tag', options.includes('content') && 'content'].filter(
|
const surfaces = ['tag', targets.includes('content') && 'content'].filter(
|
||||||
Boolean,
|
Boolean,
|
||||||
) as AppBskyActorDefs.MutedWord['targets']
|
) 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('')
|
setField('')
|
||||||
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
|
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
|
||||||
return
|
return
|
||||||
@@ -70,28 +89,37 @@ function MutedWordsInner() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// send raw value and rely on SDK as sanitization source of truth
|
// 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('')
|
setField('')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(`Failed to save muted word`, {message: e.message})
|
logger.error(`Failed to save muted word`, {message: e.message})
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
}
|
}
|
||||||
}, [_, field, options, addMutedWord, setField])
|
}, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
||||||
<View onTouchStart={Keyboard.dismiss}>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
||||||
<Trans>Add muted words and tags</Trans>
|
<Trans>Add muted words and tags</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
<Trans>
|
<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>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={[a.pb_lg]}>
|
<View style={[a.pb_sm]}>
|
||||||
<Dialog.Input
|
<Dialog.Input
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@@ -107,30 +135,135 @@ function MutedWordsInner() {
|
|||||||
}}
|
}}
|
||||||
onSubmitEditing={submit}
|
onSubmitEditing={submit}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.pb_xl, a.gap_sm]}>
|
||||||
<Toggle.Group
|
<Toggle.Group
|
||||||
label={_(msg`Toggle between muted word options.`)}
|
label={_(msg`Select how long to mute this word for.`)}
|
||||||
type="radio"
|
type="radio"
|
||||||
values={options}
|
values={durations}
|
||||||
onChange={setOptions}>
|
onChange={setDurations}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Duration:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.pt_sm,
|
gtMobile && [a.flex_row, a.align_center, a.justify_start],
|
||||||
a.py_sm,
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
|
a.justify_start,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.gap_sm,
|
a.gap_sm,
|
||||||
a.flex_wrap,
|
|
||||||
]}>
|
]}>
|
||||||
|
<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
|
<Toggle.Item
|
||||||
label={_(msg`Mute this word in post text and tags`)}
|
label={_(msg`Mute this word in post text and tags`)}
|
||||||
name="content"
|
name="content"
|
||||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
style={[a.flex_1]}>
|
||||||
<TargetToggle>
|
<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.Radio />
|
||||||
<Toggle.LabelText>
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
<Trans>Mute in text & tags</Trans>
|
<Trans>Text & tags</Trans>
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
</View>
|
</View>
|
||||||
<PageText size="sm" />
|
<PageText size="sm" />
|
||||||
@@ -140,33 +273,63 @@ function MutedWordsInner() {
|
|||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
label={_(msg`Mute this word in tags only`)}
|
label={_(msg`Mute this word in tags only`)}
|
||||||
name="tag"
|
name="tag"
|
||||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
style={[a.flex_1]}>
|
||||||
<TargetToggle>
|
<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.Radio />
|
||||||
<Toggle.LabelText>
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
<Trans>Mute in tags only</Trans>
|
<Trans>Tags only</Trans>
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
</View>
|
</View>
|
||||||
<Hashtag size="sm" />
|
<Hashtag size="sm" />
|
||||||
</TargetToggle>
|
</TargetToggle>
|
||||||
</Toggle.Item>
|
</Toggle.Item>
|
||||||
|
</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
|
<Button
|
||||||
disabled={isPending || !field}
|
disabled={isPending || !field}
|
||||||
label={_(msg`Add mute word for configured settings`)}
|
label={_(msg`Add mute word for configured settings`)}
|
||||||
size="small"
|
size="medium"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
style={[!gtMobile && [a.w_full, a.flex_0]]}
|
style={[]}
|
||||||
onPress={submit}>
|
onPress={submit}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Add</Trans>
|
<Trans>Add</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
<ButtonIcon icon={isPending ? Loader : Plus} />
|
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</Toggle.Group>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<View
|
<View
|
||||||
@@ -191,20 +354,6 @@ function MutedWordsInner() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -268,6 +417,9 @@ function MutedWordRow({
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
||||||
const control = Prompt.usePromptControl()
|
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 () => {
|
const remove = React.useCallback(async () => {
|
||||||
control.close()
|
control.close()
|
||||||
@@ -280,7 +432,7 @@ function MutedWordRow({
|
|||||||
control={control}
|
control={control}
|
||||||
title={_(msg`Are you sure?`)}
|
title={_(msg`Are you sure?`)}
|
||||||
description={_(
|
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}
|
onConfirm={remove}
|
||||||
confirmButtonCta={_(msg`Remove`)}
|
confirmButtonCta={_(msg`Remove`)}
|
||||||
@@ -289,54 +441,95 @@ function MutedWordRow({
|
|||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_between,
|
||||||
a.py_md,
|
a.py_md,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_between,
|
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.gap_md,
|
a.gap_md,
|
||||||
style,
|
style,
|
||||||
]}>
|
]}>
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.leading_snug,
|
a.leading_snug,
|
||||||
a.w_full,
|
|
||||||
a.font_bold,
|
a.font_bold,
|
||||||
t.atoms.text_contrast_high,
|
|
||||||
web({
|
web({
|
||||||
overflowWrap: 'break-word',
|
overflowWrap: 'break-word',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}),
|
}),
|
||||||
]}>
|
]}>
|
||||||
{word.value}
|
{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>
|
||||||
|
</Text>
|
||||||
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
|
</Trans>
|
||||||
{word.targets.map(target => (
|
) : (
|
||||||
<View
|
<Trans comment="Pattern: {wordValue} in tags">
|
||||||
key={target}
|
{word.value}{' '}
|
||||||
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
|
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||||
<Text
|
in{' '}
|
||||||
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
|
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
|
tags
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
|
||||||
|
{(expiryDate || word.actorTarget === 'exclude-following') && (
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Text
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Remove mute word from your list`)}
|
label={_(msg`Remove mute word from your list`)}
|
||||||
size="tiny"
|
size="tiny"
|
||||||
shape="round"
|
shape="round"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onPress={() => control.open()}
|
onPress={() => control.open()}
|
||||||
style={[a.ml_sm]}>
|
style={[a.ml_sm]}>
|
||||||
<ButtonIcon icon={isPending ? Loader : X} />
|
<ButtonIcon icon={isPending ? Loader : X} />
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ function DialogContent({
|
|||||||
|
|
||||||
const onPressAudience = (setting: ThreadgateSetting) => {
|
const onPressAudience = (setting: ThreadgateSetting) => {
|
||||||
// remove nobody
|
// remove nobody
|
||||||
let newSelected = draft.filter(v => v.type !== 'nobody')
|
let newSelected: ThreadgateSetting[] = draft.filter(
|
||||||
|
v => v.type !== 'nobody',
|
||||||
|
)
|
||||||
// toggle
|
// toggle
|
||||||
const i = newSelected.findIndex(v => isEqual(v, setting))
|
const i = newSelected.findIndex(v => isEqual(v, setting))
|
||||||
if (i === -1) {
|
if (i === -1) {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function BlockedByListDialog({
|
|||||||
<React.Fragment key={block.source.list.uri}>
|
<React.Fragment key={block.source.list.uri}>
|
||||||
{i === 0 ? null : ', '}
|
{i === 0 ? null : ', '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={block.source.list.name}
|
||||||
to={listUriToHref(block.source.list.uri)}
|
to={listUriToHref(block.source.list.uri)}
|
||||||
style={[a.text_md, a.leading_snug]}>
|
style={[a.text_md, a.leading_snug]}>
|
||||||
{block.source.list.name}
|
{block.source.list.name}
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ function HeaderReady({
|
|||||||
userBlock?: ModerationCause
|
userBlock?: ModerationCause
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const convoState = useConvo()
|
const convoState = useConvo()
|
||||||
const profile = useProfileShadow(profileUnshadowed)
|
const profile = useProfileShadow(profileUnshadowed)
|
||||||
@@ -156,6 +157,7 @@ function HeaderReady({
|
|||||||
<View style={[a.flex_1]}>
|
<View style={[a.flex_1]}>
|
||||||
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
|
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
|
||||||
<Link
|
<Link
|
||||||
|
label={_(msg`View ${displayName}'s profile`)}
|
||||||
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
|
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
|
||||||
to={makeProfileLink(profile)}>
|
to={makeProfileLink(profile)}>
|
||||||
<View style={[a.pt_2xs]}>
|
<View style={[a.pt_2xs]}>
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export function Group({children, multiple, ...props}: GroupProps) {
|
|||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.border,
|
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
|
{borderWidth: 1},
|
||||||
]}>
|
]}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -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
|
disabled?: boolean
|
||||||
labelerDid?: string
|
labelerDid?: string
|
||||||
}) {
|
}) {
|
||||||
const {i18n} = useLingui()
|
const {_, i18n} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {gtPhone} = useBreakpoints()
|
const {gtPhone} = useBreakpoints()
|
||||||
|
|
||||||
@@ -243,7 +243,10 @@ export function LabelerLabelPreference({
|
|||||||
) : isGlobalLabel ? (
|
) : isGlobalLabel ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
Configured in{' '}
|
Configured in{' '}
|
||||||
<InlineLinkText to="/moderation" style={a.text_sm}>
|
<InlineLinkText
|
||||||
|
label={_(msg`moderation settings`)}
|
||||||
|
to="/moderation"
|
||||||
|
style={a.text_sm}>
|
||||||
moderation settings
|
moderation settings
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
.
|
.
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ function Label({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {labeler, strings} = useLabelInfo(label)
|
const {labeler, strings} = useLabelInfo(label)
|
||||||
|
const sourceName = labeler
|
||||||
|
? sanitizeHandle(labeler.creator.handle, '@')
|
||||||
|
: label.src
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -169,13 +172,12 @@ function Label({
|
|||||||
<Trans>
|
<Trans>
|
||||||
Source:{' '}
|
Source:{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={sourceName}
|
||||||
to={makeProfileLink(
|
to={makeProfileLink(
|
||||||
labeler ? labeler.creator : {did: label.src, handle: ''},
|
labeler ? labeler.creator : {did: label.src, handle: ''},
|
||||||
)}
|
)}
|
||||||
onPress={() => control.close()}>
|
onPress={() => control.close()}>
|
||||||
{labeler
|
{sourceName}
|
||||||
? sanitizeHandle(labeler.creator.handle, '@')
|
|
||||||
: label.src}
|
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
</Trans>
|
</Trans>
|
||||||
)}
|
)}
|
||||||
@@ -203,6 +205,9 @@ function AppealForm({
|
|||||||
const isAccountReport = 'did' in subject
|
const isAccountReport = 'did' in subject
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const gate = useGate()
|
const gate = useGate()
|
||||||
|
const sourceName = labeler
|
||||||
|
? sanitizeHandle(labeler.creator.handle, '@')
|
||||||
|
: label.src
|
||||||
|
|
||||||
const {mutate, isPending} = useMutation({
|
const {mutate, isPending} = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -260,12 +265,13 @@ function AppealForm({
|
|||||||
<Trans>
|
<Trans>
|
||||||
This appeal will be sent to{' '}
|
This appeal will be sent to{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={sourceName}
|
||||||
to={makeProfileLink(
|
to={makeProfileLink(
|
||||||
labeler ? labeler.creator : {did: label.src, handle: ''},
|
labeler ? labeler.creator : {did: label.src, handle: ''},
|
||||||
)}
|
)}
|
||||||
onPress={() => control.close()}
|
onPress={() => control.close()}
|
||||||
style={[a.text_md, a.leading_snug]}>
|
style={[a.text_md, a.leading_snug]}>
|
||||||
{labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
|
{sourceName}
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
.
|
.
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ function ModerationDetailsDialogInner({
|
|||||||
description = (
|
description = (
|
||||||
<Trans>
|
<Trans>
|
||||||
This user is included in the{' '}
|
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}
|
{list.name}
|
||||||
</InlineLinkText>{' '}
|
</InlineLinkText>{' '}
|
||||||
list which you have blocked.
|
list which you have blocked.
|
||||||
@@ -83,7 +86,10 @@ function ModerationDetailsDialogInner({
|
|||||||
description = (
|
description = (
|
||||||
<Trans>
|
<Trans>
|
||||||
This user is included in the{' '}
|
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}
|
{list.name}
|
||||||
</InlineLinkText>{' '}
|
</InlineLinkText>{' '}
|
||||||
list which you have muted.
|
list which you have muted.
|
||||||
@@ -127,10 +133,11 @@ function ModerationDetailsDialogInner({
|
|||||||
<Trans>
|
<Trans>
|
||||||
This label was applied by{' '}
|
This label was applied by{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={desc.source || _(msg`an unknown labeler`)}
|
||||||
to={makeProfileLink({did: modcause.label.src, handle: ''})}
|
to={makeProfileLink({did: modcause.label.src, handle: ''})}
|
||||||
onPress={() => control.close()}
|
onPress={() => control.close()}
|
||||||
style={a.text_md}>
|
style={a.text_md}>
|
||||||
{desc.source}
|
{desc.source || _(msg`an unknown labeler`)}
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
.
|
.
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
|
|||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
import {CenteredView} from '#/view/com/util/Views'
|
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 {Button, ButtonText} from '#/components/Button'
|
||||||
import {
|
import {
|
||||||
ModerationDetailsDialog,
|
ModerationDetailsDialog,
|
||||||
@@ -105,6 +105,7 @@ export function ScreenHider({
|
|||||||
a.mb_md,
|
a.mb_md,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.text_center,
|
a.text_center,
|
||||||
|
a.leading_snug,
|
||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_medium,
|
||||||
]}>
|
]}>
|
||||||
{isNoPwi ? (
|
{isNoPwi ? (
|
||||||
@@ -113,8 +114,15 @@ export function ScreenHider({
|
|||||||
</Trans>
|
</Trans>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Trans>This {screenDescription} has been flagged:</Trans>
|
<Trans>This {screenDescription} has been flagged:</Trans>{' '}
|
||||||
<Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_lg,
|
||||||
|
a.font_semibold,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text,
|
||||||
|
a.ml_xs,
|
||||||
|
]}>
|
||||||
{desc.name}.{' '}
|
{desc.name}.{' '}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableWithoutFeedback
|
<TouchableWithoutFeedback
|
||||||
@@ -127,16 +135,17 @@ export function ScreenHider({
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.text_lg,
|
a.text_lg,
|
||||||
|
a.leading_snug,
|
||||||
{
|
{
|
||||||
color: t.palette.primary_500,
|
color: t.palette.primary_500,
|
||||||
// @ts-ignore web only -prf
|
|
||||||
cursor: 'pointer',
|
|
||||||
},
|
},
|
||||||
|
web({
|
||||||
|
cursor: 'pointer',
|
||||||
|
}),
|
||||||
]}>
|
]}>
|
||||||
<Trans>Learn More</Trans>
|
<Trans>Learn More</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableWithoutFeedback>
|
</TouchableWithoutFeedback>
|
||||||
|
|
||||||
<ModerationDetailsDialog control={control} modcause={blur} />
|
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||||
</>
|
</>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
@@ -6,328 +7,351 @@ import {
|
|||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {isPostInLanguage} from '../../locale/helpers'
|
import {isPostInLanguage} from '../../locale/helpers'
|
||||||
|
import {FALLBACK_MARKER_POST} from './feed/home'
|
||||||
import {ReasonFeedSource} from './feed/types'
|
import {ReasonFeedSource} from './feed/types'
|
||||||
|
|
||||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||||
|
|
||||||
export type FeedTunerFn = (
|
export type FeedTunerFn = (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
|
dryRun: boolean,
|
||||||
) => FeedViewPostsSlice[]
|
) => 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 {
|
export class FeedViewPostsSlice {
|
||||||
_reactKey: string
|
_reactKey: string
|
||||||
isFlattenedReply = false
|
_feedPost: FeedViewPost
|
||||||
|
items: FeedSliceItem[]
|
||||||
|
isIncompleteThread: boolean
|
||||||
|
isFallbackMarker: boolean
|
||||||
|
isOrphan: boolean
|
||||||
|
rootUri: string
|
||||||
|
|
||||||
constructor(public items: FeedViewPost[]) {
|
constructor(feedPost: FeedViewPost) {
|
||||||
const item = items[0]
|
const {post, reply, reason} = feedPost
|
||||||
this._reactKey = `slice-${item.post.uri}-${
|
this.items = []
|
||||||
item.reason?.indexedAt || item.post.indexedAt
|
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
|
||||||
}`
|
}`
|
||||||
|
if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) {
|
||||||
|
this.isFallbackMarker = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!AppBskyFeedPost.isRecord(post.record) ||
|
||||||
|
!AppBskyFeedPost.validateRecord(post.record).success
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
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 uri() {
|
get isQuotePost() {
|
||||||
if (this.isFlattenedReply) {
|
const embed = this._feedPost.post.embed
|
||||||
return this.items[1].post.uri
|
|
||||||
}
|
|
||||||
return this.items[0].post.uri
|
|
||||||
}
|
|
||||||
|
|
||||||
get ts() {
|
|
||||||
if (this.items[0].reason?.indexedAt) {
|
|
||||||
return this.items[0].reason.indexedAt as string
|
|
||||||
}
|
|
||||||
return this.items[0].post.indexedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
get isThread() {
|
|
||||||
return (
|
return (
|
||||||
this.items.length > 1 &&
|
AppBskyEmbedRecord.isView(embed) ||
|
||||||
this.items.every(
|
AppBskyEmbedRecordWithMedia.isView(embed)
|
||||||
item => item.post.author.did === this.items[0].post.author.did,
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get isFullThread() {
|
|
||||||
return this.isThread && !this.items[0].reply
|
|
||||||
}
|
|
||||||
|
|
||||||
get rootItem() {
|
|
||||||
if (this.isFlattenedReply) {
|
|
||||||
return this.items[1]
|
|
||||||
}
|
|
||||||
return this.items[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get isReply() {
|
get isReply() {
|
||||||
return (
|
return (
|
||||||
AppBskyFeedPost.isRecord(this.rootItem.post.record) &&
|
AppBskyFeedPost.isRecord(this._feedPost.post.record) &&
|
||||||
!!this.rootItem.post.record.reply
|
!!this._feedPost.post.record.reply
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get source(): ReasonFeedSource | undefined {
|
get reason() {
|
||||||
return this.items.find(item => '__source' in item && !!item.__source)
|
return '__source' in this._feedPost
|
||||||
?.__source as ReasonFeedSource
|
? (this._feedPost.__source as ReasonFeedSource)
|
||||||
|
: this._feedPost.reason
|
||||||
}
|
}
|
||||||
|
|
||||||
get feedContext() {
|
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) {
|
containsUri(uri: string) {
|
||||||
return !!this.items.find(item => item.post.uri === uri)
|
return !!this.items.find(item => item.post.uri === uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
isNextInThread(uri: string) {
|
getAuthors(): AuthorContext {
|
||||||
return this.items[this.items.length - 1].post.uri === uri
|
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) {
|
||||||
insert(item: FeedViewPost) {
|
grandparentAuthor = feedPost.reply.grandparentAuthor
|
||||||
const selfReplyUri = getSelfReplyUri(item)
|
}
|
||||||
const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri)
|
if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) {
|
||||||
if (i !== -1) {
|
rootAuthor = feedPost.reply.root.author
|
||||||
this.items.splice(i + 1, 0, item)
|
|
||||||
} else {
|
|
||||||
this.items.push(item)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
flattenReplyParent() {
|
author,
|
||||||
if (this.items[0].reply) {
|
parentAuthor,
|
||||||
const reply = this.items[0].reply
|
grandparentAuthor,
|
||||||
if (AppBskyFeedDefs.isPostView(reply.parent)) {
|
rootAuthor,
|
||||||
this.isFlattenedReply = true
|
|
||||||
this.items.splice(0, 0, {post: reply.parent})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
isFollowingAllAuthors(userDid: string) {
|
|
||||||
const item = this.rootItem
|
|
||||||
if (item.post.author.did === userDid) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
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 {
|
export class FeedTuner {
|
||||||
seenKeys: Set<string> = new Set()
|
seenKeys: Set<string> = new Set()
|
||||||
seenUris: Set<string> = new Set()
|
seenUris: Set<string> = new Set()
|
||||||
|
seenRootUris: Set<string> = new Set()
|
||||||
|
|
||||||
constructor(public tunerFns: FeedTunerFn[]) {}
|
constructor(public tunerFns: FeedTunerFn[]) {}
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.seenKeys.clear()
|
|
||||||
this.seenUris.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
tune(
|
tune(
|
||||||
feed: FeedViewPost[],
|
feed: FeedViewPost[],
|
||||||
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
|
{dryRun}: {dryRun: boolean} = {
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
maintainOrder: false,
|
|
||||||
},
|
},
|
||||||
): FeedViewPostsSlice[] {
|
): FeedViewPostsSlice[] {
|
||||||
let slices: FeedViewPostsSlice[] = []
|
let slices: FeedViewPostsSlice[] = feed
|
||||||
|
.map(item => new FeedViewPostsSlice(item))
|
||||||
// remove posts that are replies, but which don't have the parent
|
.filter(s => s.items.length > 0 || s.isFallbackMarker)
|
||||||
// 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
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
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
|
// run the custom tuners
|
||||||
for (const tunerFn of this.tunerFns) {
|
for (const tunerFn of this.tunerFns) {
|
||||||
slices = tunerFn(this, slices.slice())
|
slices = tunerFn(this, slices.slice(), dryRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 => {
|
slices = slices.filter(slice => {
|
||||||
if (this.seenKeys.has(slice._reactKey)) {
|
if (this.seenKeys.has(slice._reactKey)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for (const item of slice.items) {
|
// 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)
|
this.seenUris.add(item.post.uri)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dryRun) {
|
||||||
this.seenKeys.add(slice._reactKey)
|
this.seenKeys.add(slice._reactKey)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
static removeReplies(
|
||||||
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(
|
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
): FeedViewPostsSlice[] {
|
_dryRun: boolean,
|
||||||
// remove duplicates caused by reposts
|
) {
|
||||||
for (let i = 0; i < slices.length; i++) {
|
for (let i = 0; i < slices.length; i++) {
|
||||||
const item1 = slices[i]
|
const slice = slices[i]
|
||||||
for (let j = i + 1; j < slices.length; j++) {
|
if (
|
||||||
const item2 = slices[j]
|
slice.isReply &&
|
||||||
if (item2.isThread) {
|
!slice.isRepost &&
|
||||||
// dont dedup items that are rendering in a thread as this can cause rendering errors
|
// This is not perfect but it's close as we can get to
|
||||||
continue
|
// detecting threads without having to peek ahead.
|
||||||
|
!areSameAuthor(slice.getAuthors())
|
||||||
|
) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
}
|
}
|
||||||
if (item1.containsUri(item2.items[0].post.uri)) {
|
}
|
||||||
slices.splice(j, 1)
|
return slices
|
||||||
j--
|
}
|
||||||
|
|
||||||
|
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
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
static thresholdRepliesOnly({
|
static followedRepliesOnly({userDid}: {userDid: string}) {
|
||||||
userDid,
|
|
||||||
minLikes,
|
|
||||||
followedOnly,
|
|
||||||
}: {
|
|
||||||
userDid: string
|
|
||||||
minLikes: number
|
|
||||||
followedOnly: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
// remove any replies without at least minLikes likes
|
for (let i = 0; i < slices.length; i++) {
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
const slice = slices[i]
|
const slice = slices[i]
|
||||||
if (slice.isFullThread || !slice.isReply) {
|
if (
|
||||||
continue
|
slice.isReply &&
|
||||||
}
|
!slice.isRepost &&
|
||||||
|
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
|
||||||
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)) {
|
|
||||||
slices.splice(i, 1)
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return slices
|
return slices
|
||||||
@@ -345,6 +369,7 @@ export class FeedTuner {
|
|||||||
return (
|
return (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
const candidateSlices = slices.slice()
|
const candidateSlices = slices.slice()
|
||||||
|
|
||||||
@@ -353,7 +378,7 @@ export class FeedTuner {
|
|||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
for (let i = 0; i < slices.length; i++) {
|
||||||
let hasPreferredLang = false
|
let hasPreferredLang = false
|
||||||
for (const item of slices[i].items) {
|
for (const item of slices[i].items) {
|
||||||
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
||||||
@@ -379,16 +404,66 @@ export class FeedTuner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelfReplyUri(item: FeedViewPost): string | undefined {
|
function areSameAuthor(authors: AuthorContext): boolean {
|
||||||
if (item.reply) {
|
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||||
if (
|
const authorDid = author.did
|
||||||
AppBskyFeedDefs.isPostView(item.reply.parent) &&
|
if (parentAuthor && parentAuthor.did !== authorDid) {
|
||||||
!AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies
|
return false
|
||||||
) {
|
|
||||||
return item.reply.parent.author.did === item.post.author.did
|
|
||||||
? item.reply.parent.uri
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
|
if (grandparentAuthor && grandparentAuthor.did !== authorDid) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return undefined
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,12 +193,6 @@ class MergeFeedSource {
|
|||||||
return this.hasMore && this.queue.length === 0
|
return this.hasMore && this.queue.length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.cursor = undefined
|
|
||||||
this.queue = []
|
|
||||||
this.hasMore = true
|
|
||||||
}
|
|
||||||
|
|
||||||
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
|
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
|
||||||
return this.queue.splice(0, n)
|
return this.queue.splice(0, n)
|
||||||
}
|
}
|
||||||
@@ -232,11 +226,6 @@ class MergeFeedSource {
|
|||||||
class MergeFeedSource_Following extends MergeFeedSource {
|
class MergeFeedSource_Following extends MergeFeedSource {
|
||||||
tuner = new FeedTuner(this.feedTuners)
|
tuner = new FeedTuner(this.feedTuners)
|
||||||
|
|
||||||
reset() {
|
|
||||||
super.reset()
|
|
||||||
this.tuner.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchNext(n: number) {
|
async fetchNext(n: number) {
|
||||||
return this._fetchNextInner(n)
|
return this._fetchNextInner(n)
|
||||||
}
|
}
|
||||||
@@ -249,9 +238,8 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
|||||||
// run the tuner pre-emptively to ensure better mixing
|
// run the tuner pre-emptively to ensure better mixing
|
||||||
const slices = this.tuner.tune(res.data.feed, {
|
const slices = this.tuner.tune(res.data.feed, {
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
maintainOrder: true,
|
|
||||||
})
|
})
|
||||||
res.data.feed = slices.map(slice => slice.rootItem)
|
res.data.feed = slices.map(slice => slice._feedPost)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ interface PostOpts {
|
|||||||
uri: string
|
uri: string
|
||||||
cid: string
|
cid: string
|
||||||
}
|
}
|
||||||
|
video?: {
|
||||||
|
uri: string
|
||||||
|
cid: string
|
||||||
|
}
|
||||||
extLink?: ExternalEmbedDraft
|
extLink?: ExternalEmbedDraft
|
||||||
images?: ImageModel[]
|
images?: ImageModel[]
|
||||||
labels?: string[]
|
labels?: string[]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from 'react'
|
|||||||
export const useDedupe = () => {
|
export const useDedupe = () => {
|
||||||
const canDo = React.useRef(true)
|
const canDo = React.useRef(true)
|
||||||
|
|
||||||
return React.useRef((cb: () => unknown) => {
|
return React.useCallback((cb: () => unknown) => {
|
||||||
if (canDo.current) {
|
if (canDo.current) {
|
||||||
canDo.current = false
|
canDo.current = false
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -13,5 +13,5 @@ export const useDedupe = () => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}).current
|
}, [])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
if (def.identifier === 'porn' || def.identifier === 'sexual') {
|
||||||
strings.name = 'Adult Content'
|
strings.name = _(msg`Adult Content`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type CommonNavigatorParams = {
|
|||||||
PreferencesThreads: undefined
|
PreferencesThreads: undefined
|
||||||
PreferencesExternalEmbeds: undefined
|
PreferencesExternalEmbeds: undefined
|
||||||
AccessibilitySettings: undefined
|
AccessibilitySettings: undefined
|
||||||
|
AppearanceSettings: undefined
|
||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
MessagesConversation: {conversation: string; embed?: string}
|
MessagesConversation: {conversation: string; embed?: string}
|
||||||
|
|||||||
@@ -215,6 +215,12 @@ export type LogEvents = {
|
|||||||
|
|
||||||
'profile:header:suggestedFollowsCard:press': {}
|
'profile:header:suggestedFollowsCard:press': {}
|
||||||
|
|
||||||
|
'debug:followingPrefs': {
|
||||||
|
followingShowRepliesFromPref: 'all' | 'following' | 'off'
|
||||||
|
followingRepliesMinLikePref: number
|
||||||
|
}
|
||||||
|
'debug:followingDisplayed': {}
|
||||||
|
|
||||||
'test:all:always': {}
|
'test:all:always': {}
|
||||||
'test:all:sometimes': {}
|
'test:all:sometimes': {}
|
||||||
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ export type Gate =
|
|||||||
| 'suggested_feeds_interstitial'
|
| 'suggested_feeds_interstitial'
|
||||||
| 'suggested_follows_interstitial'
|
| 'suggested_follows_interstitial'
|
||||||
| 'ungroup_follow_backs'
|
| 'ungroup_follow_backs'
|
||||||
|
| 'video_debug'
|
||||||
| 'videos'
|
| 'videos'
|
||||||
| 'small_avi_thumb'
|
| 'small_avi_thumb'
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {Platform} from 'react-native'
|
import {Platform} from 'react-native'
|
||||||
import {isReducedMotion} from 'react-native-reanimated'
|
|
||||||
import {getLocales} from 'expo-localization'
|
import {getLocales} from 'expo-localization'
|
||||||
|
|
||||||
import {fixLegacyLanguageCode} from '#/locale/helpers'
|
import {fixLegacyLanguageCode} from '#/locale/helpers'
|
||||||
@@ -15,11 +14,10 @@ export const isMobileWeb =
|
|||||||
isWeb &&
|
isWeb &&
|
||||||
// @ts-ignore we know window exists -prf
|
// @ts-ignore we know window exists -prf
|
||||||
global.window.matchMedia(isMobileWebMediaQuery)?.matches
|
global.window.matchMedia(isMobileWebMediaQuery)?.matches
|
||||||
|
export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent)
|
||||||
|
|
||||||
export const deviceLocales = dedupArray(
|
export const deviceLocales = dedupArray(
|
||||||
getLocales?.()
|
getLocales?.()
|
||||||
.map?.(locale => fixLegacyLanguageCode(locale.languageCode))
|
.map?.(locale => fixLegacyLanguageCode(locale.languageCode))
|
||||||
.filter(code => typeof code === 'string'),
|
.filter(code => typeof code === 'string'),
|
||||||
) as string[]
|
) as string[]
|
||||||
|
|
||||||
export const prefersReducedMotion = isReducedMotion()
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const router = new Router({
|
|||||||
PreferencesThreads: '/settings/threads',
|
PreferencesThreads: '/settings/threads',
|
||||||
PreferencesExternalEmbeds: '/settings/external-embeds',
|
PreferencesExternalEmbeds: '/settings/external-embeds',
|
||||||
AccessibilitySettings: '/settings/accessibility',
|
AccessibilitySettings: '/settings/accessibility',
|
||||||
|
AppearanceSettings: '/settings/appearance',
|
||||||
SavedFeeds: '/settings/saved-feeds',
|
SavedFeeds: '/settings/saved-feeds',
|
||||||
Support: '/support',
|
Support: '/support',
|
||||||
PrivacyPolicy: '/support/privacy',
|
PrivacyPolicy: '/support/privacy',
|
||||||
|
|||||||
@@ -387,9 +387,6 @@ export function MessagesList({
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
disableFullWindowScroll={true}
|
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}
|
disableVirtualization={true}
|
||||||
style={animatedListStyle}
|
style={animatedListStyle}
|
||||||
// The extra two items account for the header and the footer components
|
// The extra two items account for the header and the footer components
|
||||||
|
|||||||
@@ -240,7 +240,10 @@ export function ModerationScreenInner({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Link testID="moderationlistsBtn" to="/moderation/modlists">
|
<Link
|
||||||
|
label={_(msg`View your moderation lists`)}
|
||||||
|
testID="moderationlistsBtn"
|
||||||
|
to="/moderation/modlists">
|
||||||
{state => (
|
{state => (
|
||||||
<SubItem
|
<SubItem
|
||||||
title={_(msg`Moderation lists`)}
|
title={_(msg`Moderation lists`)}
|
||||||
@@ -252,7 +255,10 @@ export function ModerationScreenInner({
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Link testID="mutedAccountsBtn" to="/moderation/muted-accounts">
|
<Link
|
||||||
|
label={_(msg`View your muted accounts`)}
|
||||||
|
testID="mutedAccountsBtn"
|
||||||
|
to="/moderation/muted-accounts">
|
||||||
{state => (
|
{state => (
|
||||||
<SubItem
|
<SubItem
|
||||||
title={_(msg`Muted accounts`)}
|
title={_(msg`Muted accounts`)}
|
||||||
@@ -264,7 +270,10 @@ export function ModerationScreenInner({
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts">
|
<Link
|
||||||
|
label={_(msg`View your blocked accounts`)}
|
||||||
|
testID="blockedAccountsBtn"
|
||||||
|
to="/moderation/blocked-accounts">
|
||||||
{state => (
|
{state => (
|
||||||
<SubItem
|
<SubItem
|
||||||
title={_(msg`Blocked accounts`)}
|
title={_(msg`Blocked accounts`)}
|
||||||
@@ -356,6 +365,7 @@ export function ModerationScreenInner({
|
|||||||
<Trans>
|
<Trans>
|
||||||
Adult content can only be enabled via the Web at{' '}
|
Adult content can only be enabled via the Web at{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={_(msg`The Bluesky web application`)}
|
||||||
to=""
|
to=""
|
||||||
onPress={evt => {
|
onPress={evt => {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
@@ -569,7 +579,9 @@ function PwiOptOut() {
|
|||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</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>
|
<Trans>Learn more about what is public on Bluesky.</Trans>
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const ProfileFeedSection = React.forwardRef<
|
|||||||
headerOffset={headerHeight}
|
headerOffset={headerHeight}
|
||||||
renderEndOfFeed={ProfileEndOfFeed}
|
renderEndOfFeed={ProfileEndOfFeed}
|
||||||
ignoreFilterFor={ignoreFilterFor}
|
ignoreFilterFor={ignoreFilterFor}
|
||||||
|
outsideHeaderOffset={headerHeight}
|
||||||
/>
|
/>
|
||||||
{(isScrolledDown || hasNew) && (
|
{(isScrolledDown || hasNew) && (
|
||||||
<LoadLatestBtn
|
<LoadLatestBtn
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -45,14 +45,20 @@ export const Policies = ({
|
|||||||
const els = []
|
const els = []
|
||||||
if (tos) {
|
if (tos) {
|
||||||
els.push(
|
els.push(
|
||||||
<InlineLinkText key="tos" to={tos}>
|
<InlineLinkText
|
||||||
|
label={_(msg`Read the Bluesky Terms of Service`)}
|
||||||
|
key="tos"
|
||||||
|
to={tos}>
|
||||||
{_(msg`Terms of Service`)}
|
{_(msg`Terms of Service`)}
|
||||||
</InlineLinkText>,
|
</InlineLinkText>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (pp) {
|
if (pp) {
|
||||||
els.push(
|
els.push(
|
||||||
<InlineLinkText key="pp" to={pp}>
|
<InlineLinkText
|
||||||
|
label={_(msg`Read the Bluesky Privacy Policy`)}
|
||||||
|
key="pp"
|
||||||
|
to={pp}>
|
||||||
{_(msg`Privacy Policy`)}
|
{_(msg`Privacy Policy`)}
|
||||||
</InlineLinkText>,
|
</InlineLinkText>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
|||||||
<Text style={[t.atoms.text, !gtMobile && a.text_md]}>
|
<Text style={[t.atoms.text, !gtMobile && a.text_md]}>
|
||||||
<Trans>Having trouble?</Trans>{' '}
|
<Trans>Having trouble?</Trans>{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={_(msg`Contact support`)}
|
||||||
to={FEEDBACK_FORM_URL({email: state.email})}
|
to={FEEDBACK_FORM_URL({email: state.email})}
|
||||||
style={[!gtMobile && a.text_md]}>
|
style={[!gtMobile && a.text_md]}>
|
||||||
<Trans>Contact support</Trans>
|
<Trans>Contact support</Trans>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {useA11y} from '#/state/a11y'
|
|||||||
import {DISCOVER_FEED_URI} from 'lib/constants'
|
import {DISCOVER_FEED_URI} from 'lib/constants'
|
||||||
import {
|
import {
|
||||||
useGetPopularFeedsQuery,
|
useGetPopularFeedsQuery,
|
||||||
|
usePopularFeedsSearch,
|
||||||
useSavedFeeds,
|
useSavedFeeds,
|
||||||
useSearchPopularFeedsQuery,
|
|
||||||
} from 'state/queries/feed'
|
} from 'state/queries/feed'
|
||||||
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
||||||
import {List} from 'view/com/util/List'
|
import {List} from 'view/com/util/List'
|
||||||
@@ -59,7 +59,7 @@ export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const {data: searchedFeeds, isFetching: isFetchingSearchedFeeds} =
|
const {data: searchedFeeds, isFetching: isFetchingSearchedFeeds} =
|
||||||
useSearchPopularFeedsQuery({q: throttledQuery})
|
usePopularFeedsSearch({query: throttledQuery})
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
!isFetchedSavedFeeds || isLoadingPopularFeeds || isFetchingSearchedFeeds
|
!isFetchedSavedFeeds || isLoadingPopularFeeds || isFetchingSearchedFeeds
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {AccessibilityInfo} from 'react-native'
|
import {AccessibilityInfo} from 'react-native'
|
||||||
import {isReducedMotion} from 'react-native-reanimated'
|
|
||||||
|
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {PlatformInfo} from '../../modules/expo-bluesky-swiss-army'
|
||||||
|
|
||||||
const Context = React.createContext({
|
const Context = React.createContext({
|
||||||
reduceMotionEnabled: false,
|
reduceMotionEnabled: false,
|
||||||
@@ -15,7 +15,7 @@ export function useA11y() {
|
|||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() =>
|
const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() =>
|
||||||
isReducedMotion(),
|
PlatformInfo.getIsReducedMotionEnabled(),
|
||||||
)
|
)
|
||||||
const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false)
|
const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false)
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
|||||||
toString({
|
toString({
|
||||||
item: postItem.uri,
|
item: postItem.uri,
|
||||||
event: 'app.bsky.feed.defs#interactionSeen',
|
event: 'app.bsky.feed.defs#interactionSeen',
|
||||||
feedContext: postItem.feedContext,
|
feedContext: slice.feedContext,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
sendToFeed()
|
sendToFeed()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import * as persisted from '#/state/persisted'
|
import * as persisted from '#/state/persisted'
|
||||||
|
|
||||||
type StateContext = persisted.Schema['invites']
|
type StateContext = persisted.Schema['invites']
|
||||||
@@ -35,8 +36,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate('invites', nextInvites => {
|
||||||
setState(persisted.get('invites'))
|
setState(nextInvites)
|
||||||
})
|
})
|
||||||
}, [setState])
|
}, [setState])
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
@@ -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 {logger} from '#/logger'
|
||||||
import {migrate} from '#/state/persisted/legacy'
|
import {
|
||||||
import {defaults, Schema} from '#/state/persisted/schema'
|
defaults,
|
||||||
import * as store from '#/state/persisted/store'
|
Schema,
|
||||||
|
tryParse,
|
||||||
|
tryStringify,
|
||||||
|
} from '#/state/persisted/schema'
|
||||||
|
import {PersistedApi} from './types'
|
||||||
|
|
||||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||||
export {defaults} from '#/state/persisted/schema'
|
export {defaults} from '#/state/persisted/schema'
|
||||||
|
|
||||||
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||||
const UPDATE_EVENT = 'BSKY_UPDATE'
|
|
||||||
|
|
||||||
let _state: Schema = defaults
|
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() {
|
export async function init() {
|
||||||
logger.debug('persisted state: initializing')
|
const stored = await readFromStorage()
|
||||||
|
if (stored) {
|
||||||
broadcast.onmessage = onBroadcastMessage
|
_state = stored
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
init satisfies PersistedApi['init']
|
||||||
|
|
||||||
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
||||||
return _state[key]
|
return _state[key]
|
||||||
}
|
}
|
||||||
|
get satisfies PersistedApi['get']
|
||||||
|
|
||||||
export async function write<K extends keyof Schema>(
|
export async function write<K extends keyof Schema>(
|
||||||
key: K,
|
key: K,
|
||||||
value: Schema[K],
|
value: Schema[K],
|
||||||
): Promise<void> {
|
): 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 {
|
try {
|
||||||
_state[key] = value
|
await AsyncStorage.removeItem(BSKY_STORAGE)
|
||||||
await store.write(_state)
|
} catch (e: any) {
|
||||||
// must happen on next tick, otherwise the tab will read stale storage data
|
logger.error(`persisted store: failed to clear`, {message: e.toString()})
|
||||||
setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0)
|
}
|
||||||
logger.debug(`persisted state: wrote root state to storage`, {
|
}
|
||||||
updatedKey: key,
|
clearStorage satisfies PersistedApi['clearStorage']
|
||||||
})
|
|
||||||
|
async function writeToStorage(value: Schema) {
|
||||||
|
const rawData = tryStringify(value)
|
||||||
|
if (rawData) {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(BSKY_STORAGE, rawData)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`persisted state: failed writing root state to storage`, {
|
logger.error(`persisted state: failed writing root state to storage`, {
|
||||||
message: e,
|
message: e,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onUpdate(cb: () => void): () => void {
|
async function readFromStorage(): Promise<Schema | undefined> {
|
||||||
_emitter.addListener('update', cb)
|
let rawData: string | null = null
|
||||||
return () => _emitter.removeListener('update', cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onBroadcastMessage({data}: MessageEvent) {
|
|
||||||
// validate event
|
|
||||||
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
|
|
||||||
try {
|
try {
|
||||||
// read next state, possibly updated by another tab
|
rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
||||||
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`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(`persisted state: failed reading root state from storage`, {
|
||||||
`persisted state: failed handling update from broadcast channel`,
|
|
||||||
{
|
|
||||||
message: e,
|
message: e,
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if (rawData) {
|
||||||
|
return tryParse(rawData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import {z} from 'zod'
|
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
|
const externalEmbedOptions = ['show', 'hide'] as const
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ const currentAccountSchema = accountSchema.extend({
|
|||||||
})
|
})
|
||||||
export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
|
export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
|
||||||
|
|
||||||
export const schema = z.object({
|
const schema = z.object({
|
||||||
colorMode: z.enum(['system', 'light', 'dark']),
|
colorMode: z.enum(['system', 'light', 'dark']),
|
||||||
darkTheme: z.enum(['dim', 'dark']).optional(),
|
darkTheme: z.enum(['dim', 'dark']).optional(),
|
||||||
session: z.object({
|
session: z.object({
|
||||||
@@ -89,6 +91,7 @@ export const schema = z.object({
|
|||||||
disableAutoplay: z.boolean().optional(),
|
disableAutoplay: z.boolean().optional(),
|
||||||
kawaii: z.boolean().optional(),
|
kawaii: z.boolean().optional(),
|
||||||
hasCheckedForStarterPack: z.boolean().optional(),
|
hasCheckedForStarterPack: z.boolean().optional(),
|
||||||
|
subtitlesEnabled: z.boolean().optional(),
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
mutedThreads: z.array(z.string()),
|
mutedThreads: z.array(z.string()),
|
||||||
})
|
})
|
||||||
@@ -128,7 +131,48 @@ export const defaults: Schema = {
|
|||||||
lastSelectedHomeFeed: undefined,
|
lastSelectedHomeFeed: undefined,
|
||||||
pdsAddressHistory: [],
|
pdsAddressHistory: [],
|
||||||
disableHaptics: false,
|
disableHaptics: false,
|
||||||
disableAutoplay: prefersReducedMotion,
|
disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
|
||||||
kawaii: false,
|
kawaii: false,
|
||||||
hasCheckedForStarterPack: 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate(
|
||||||
setState(persisted.get('requireAltTextEnabled'))
|
'requireAltTextEnabled',
|
||||||
})
|
nextRequireAltTextEnabled => {
|
||||||
|
setState(nextRequireAltTextEnabled)
|
||||||
|
},
|
||||||
|
)
|
||||||
}, [setStateWrapped])
|
}, [setStateWrapped])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||