Merge remote-tracking branch 'origin/main' into reply-qp-moderation
* origin/main: (30 commits) Show just-posted replies above OP replies (#4901) Remove client filtering of starter packs (#4753) Remove show_avi_follow_button (#4900) Remove native_pwi_disabled (#4896) Fix overflow on posts (#4899) Move onPressReply into child component (#4898) Remove new_user_progress_guide (#4895) Remove explore_page_profile_card_social_proof (#4894) Remove ungroup_follow_backs gate (#4893) Remove unnecessary state update for reply gate (#4897) Include follow-based suggestions in interstitial (#4889) Cleanup flags (#4891) ALF suggested follows in profile header (#4828) 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) ...
@@ -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 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 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 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 |
@@ -41,10 +41,10 @@ func run(args []string) {
|
||||
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ogcard-host",
|
||||
Usage: "scheme, hostname, and port of ogcard service",
|
||||
Name: "ogcard-host",
|
||||
Usage: "scheme, hostname, and port of ogcard service",
|
||||
Required: false,
|
||||
EnvVars: []string{"OGCARD_HOST"},
|
||||
EnvVars: []string{"OGCARD_HOST"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-address",
|
||||
@@ -67,6 +67,13 @@ func run(args []string) {
|
||||
Required: false,
|
||||
EnvVars: []string{"DEBUG"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "basic-auth-password",
|
||||
Usage: "optional password to restrict access to web interface",
|
||||
Required: false,
|
||||
Value: "",
|
||||
EnvVars: []string{"BASIC_AUTH_PASSWORD"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -48,6 +49,7 @@ func serve(cctx *cli.Context) error {
|
||||
appviewHost := cctx.String("appview-host")
|
||||
ogcardHost := cctx.String("ogcard-host")
|
||||
linkHost := cctx.String("link-host")
|
||||
basicAuthPassword := cctx.String("basic-auth-password")
|
||||
|
||||
// Echo
|
||||
e := echo.New()
|
||||
@@ -140,6 +142,18 @@ func serve(cctx *cli.Context) error {
|
||||
},
|
||||
}))
|
||||
|
||||
// optional password gating of entire web interface
|
||||
if basicAuthPassword != "" {
|
||||
e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
|
||||
// Be careful to use constant time comparison to prevent timing attacks
|
||||
if subtle.ConstantTimeCompare([]byte(username), []byte("admin")) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(password), []byte(basicAuthPassword)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}))
|
||||
}
|
||||
|
||||
// redirect trailing slash to non-trailing slash.
|
||||
// all of our current endpoints have no trailing slash.
|
||||
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
|
||||
|
||||
@@ -95,3 +95,16 @@ jest.mock('expo-application', () => ({
|
||||
nativeApplicationVersion: '1.0.0',
|
||||
nativeBuildVersion: '1',
|
||||
}))
|
||||
|
||||
jest.mock('expo-modules-core', () => ({
|
||||
requireNativeModule: jest.fn().mockImplementation(moduleName => {
|
||||
if (moduleName === 'ExpoPlatformInfo') {
|
||||
return {
|
||||
getIsReducedMotionEnabled: () => false,
|
||||
}
|
||||
}
|
||||
}),
|
||||
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
|
||||
return () => null
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package expo.modules.blueskyswissarmy.platforminfo
|
||||
|
||||
import android.provider.Settings
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class ExpoPlatformInfoModule : Module() {
|
||||
override fun definition() =
|
||||
ModuleDefinition {
|
||||
Name("ExpoPlatformInfo")
|
||||
|
||||
// See https://github.com/software-mansion/react-native-reanimated/blob/7df5fd57d608fe25724608835461cd925ff5151d/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeProxy/NativeProxyCommon.java#L242
|
||||
Function("getIsReducedMotionEnabled") {
|
||||
val resolver = appContext.reactContext?.contentResolver ?: return@Function false
|
||||
val scale = Settings.Global.getString(resolver, Settings.Global.TRANSITION_ANIMATION_SCALE) ?: return@Function false
|
||||
|
||||
try {
|
||||
return@Function scale.toFloat() == 0f
|
||||
} catch (_: Error) {
|
||||
return@Function false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package expo.modules.blueskyswissarmy.visibilityview
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class ExpoBlueskyVisibilityViewModule : Module() {
|
||||
override fun definition() =
|
||||
ModuleDefinition {
|
||||
Name("ExpoBlueskyVisibilityView")
|
||||
|
||||
AsyncFunction("updateActiveViewAsync") {
|
||||
VisibilityViewManager.updateActiveView()
|
||||
}
|
||||
|
||||
View(VisibilityView::class) {
|
||||
Events(arrayOf("onChangeStatus"))
|
||||
|
||||
Prop("enabled") { view: VisibilityView, prop: Boolean ->
|
||||
view.isViewEnabled = prop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package expo.modules.blueskyswissarmy.visibilityview
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
|
||||
class VisibilityView(
|
||||
context: Context,
|
||||
appContext: AppContext,
|
||||
) : ExpoView(context, appContext) {
|
||||
var isViewEnabled: Boolean = false
|
||||
|
||||
private val onChangeStatus by EventDispatcher()
|
||||
|
||||
private var isCurrentlyActive = false
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
VisibilityViewManager.addView(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
VisibilityViewManager.removeView(this)
|
||||
}
|
||||
|
||||
fun setIsCurrentlyActive(isActive: Boolean) {
|
||||
if (isCurrentlyActive == isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isCurrentlyActive = isActive
|
||||
this.onChangeStatus(
|
||||
mapOf(
|
||||
"isActive" to isActive,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun getPositionOnScreen(): Rect? {
|
||||
if (!this.isShown) {
|
||||
return null
|
||||
}
|
||||
|
||||
val screenPosition = intArrayOf(0, 0)
|
||||
this.getLocationInWindow(screenPosition)
|
||||
return Rect(
|
||||
screenPosition[0],
|
||||
screenPosition[1],
|
||||
screenPosition[0] + this.width,
|
||||
screenPosition[1] + this.height,
|
||||
)
|
||||
}
|
||||
|
||||
fun isViewableEnough(): Boolean {
|
||||
val positionOnScreen = this.getPositionOnScreen() ?: return false
|
||||
val visibleArea = positionOnScreen.width() * positionOnScreen.height()
|
||||
val totalArea = this.width * this.height
|
||||
return visibleArea >= 0.5 * totalArea
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package expo.modules.blueskyswissarmy.visibilityview
|
||||
|
||||
import android.graphics.Rect
|
||||
|
||||
class VisibilityViewManager {
|
||||
companion object {
|
||||
private val views = HashMap<Int, VisibilityView>()
|
||||
private var currentlyActiveView: VisibilityView? = null
|
||||
private var prevCount = 0
|
||||
|
||||
fun addView(view: VisibilityView) {
|
||||
this.views[view.id] = view
|
||||
|
||||
if (this.prevCount == 0) {
|
||||
this.updateActiveView()
|
||||
}
|
||||
this.prevCount = this.views.count()
|
||||
}
|
||||
|
||||
fun removeView(view: VisibilityView) {
|
||||
this.views.remove(view.id)
|
||||
this.prevCount = this.views.count()
|
||||
}
|
||||
|
||||
fun updateActiveView() {
|
||||
var activeView: VisibilityView? = null
|
||||
val count = this.views.count()
|
||||
|
||||
if (count == 1) {
|
||||
val view = this.views.values.first()
|
||||
if (view.isViewableEnough()) {
|
||||
activeView = view
|
||||
}
|
||||
} else if (count > 1) {
|
||||
val views = this.views.values
|
||||
var mostVisibleView: VisibilityView? = null
|
||||
var mostVisiblePosition: Rect? = null
|
||||
|
||||
views.forEach { view ->
|
||||
if (!view.isViewableEnough()) {
|
||||
return
|
||||
}
|
||||
|
||||
val position = view.getPositionOnScreen() ?: return@forEach
|
||||
val topY = position.centerY() - (position.height() / 2)
|
||||
|
||||
if (topY >= 150) {
|
||||
if (mostVisiblePosition == null) {
|
||||
mostVisiblePosition = position
|
||||
}
|
||||
|
||||
if (position.centerY() <= mostVisiblePosition!!.centerY()) {
|
||||
mostVisibleView = view
|
||||
mostVisiblePosition = position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeView = mostVisibleView
|
||||
}
|
||||
|
||||
if (activeView == this.currentlyActiveView) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clearActiveView()
|
||||
if (activeView != null) {
|
||||
this.setActiveView(activeView)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearActiveView() {
|
||||
this.currentlyActiveView?.setIsCurrentlyActive(false)
|
||||
this.currentlyActiveView = null
|
||||
}
|
||||
|
||||
private fun setActiveView(view: VisibilityView) {
|
||||
view.setIsCurrentlyActive(true)
|
||||
this.currentlyActiveView = view
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos", "android", "web"],
|
||||
"ios": {
|
||||
"modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"]
|
||||
"modules": [
|
||||
"ExpoBlueskySharedPrefsModule",
|
||||
"ExpoBlueskyReferrerModule",
|
||||
"ExpoBlueskyVisibilityViewModule",
|
||||
"ExpoPlatformInfoModule"
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
"modules": [
|
||||
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
|
||||
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule"
|
||||
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
|
||||
"expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule",
|
||||
"expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as PlatformInfo from './src/PlatformInfo'
|
||||
import * as Referrer from './src/Referrer'
|
||||
import * as SharedPrefs from './src/SharedPrefs'
|
||||
import VisibilityView from './src/VisibilityView'
|
||||
|
||||
export {Referrer, SharedPrefs}
|
||||
export {PlatformInfo, Referrer, SharedPrefs, VisibilityView}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoPlatformInfoModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoPlatformInfo")
|
||||
|
||||
Function("getIsReducedMotionEnabled") {
|
||||
return UIAccessibility.isReduceMotionEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoBlueskyVisibilityViewModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoBlueskyVisibilityView")
|
||||
|
||||
AsyncFunction("updateActiveViewAsync") {
|
||||
VisibilityViewManager.shared.updateActiveView()
|
||||
}
|
||||
|
||||
View(VisibilityView.self) {
|
||||
Events([
|
||||
"onChangeStatus"
|
||||
])
|
||||
|
||||
Prop("enabled") { (view: VisibilityView, prop: Bool) in
|
||||
view.enabled = prop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
|
||||
class VisibilityViewManager {
|
||||
static let shared = VisibilityViewManager()
|
||||
|
||||
private let views = NSHashTable<VisibilityView>(options: .weakMemory)
|
||||
private var currentlyActiveView: VisibilityView?
|
||||
private var screenHeight: CGFloat = UIScreen.main.bounds.height
|
||||
private var prevCount = 0
|
||||
|
||||
func addView(_ view: VisibilityView) {
|
||||
self.views.add(view)
|
||||
|
||||
if self.prevCount == 0 {
|
||||
self.updateActiveView()
|
||||
}
|
||||
self.prevCount = self.views.count
|
||||
}
|
||||
|
||||
func removeView(_ view: VisibilityView) {
|
||||
self.views.remove(view)
|
||||
self.prevCount = self.views.count
|
||||
}
|
||||
|
||||
func updateActiveView() {
|
||||
DispatchQueue.main.async {
|
||||
var activeView: VisibilityView?
|
||||
|
||||
if self.views.count == 1 {
|
||||
let view = self.views.allObjects[0]
|
||||
if view.isViewableEnough() {
|
||||
activeView = view
|
||||
}
|
||||
} else if self.views.count > 1 {
|
||||
let views = self.views.allObjects
|
||||
var mostVisibleView: VisibilityView?
|
||||
var mostVisiblePosition: CGRect?
|
||||
|
||||
views.forEach { view in
|
||||
if !view.isViewableEnough() {
|
||||
return
|
||||
}
|
||||
|
||||
guard let position = view.getPositionOnScreen() else {
|
||||
return
|
||||
}
|
||||
|
||||
if position.minY >= 150 {
|
||||
if mostVisiblePosition == nil {
|
||||
mostVisiblePosition = position
|
||||
}
|
||||
|
||||
if let unwrapped = mostVisiblePosition,
|
||||
position.minY <= unwrapped.minY {
|
||||
mostVisibleView = view
|
||||
mostVisiblePosition = position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeView = mostVisibleView
|
||||
}
|
||||
|
||||
if activeView == self.currentlyActiveView {
|
||||
return
|
||||
}
|
||||
|
||||
self.clearActiveView()
|
||||
if let view = activeView {
|
||||
self.setActiveView(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clearActiveView() {
|
||||
if let currentlyActiveView = self.currentlyActiveView {
|
||||
currentlyActiveView.setIsCurrentlyActive(isActive: false)
|
||||
self.currentlyActiveView = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func setActiveView(_ view: VisibilityView) {
|
||||
view.setIsCurrentlyActive(isActive: true)
|
||||
self.currentlyActiveView = view
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
class VisibilityView: ExpoView {
|
||||
var enabled = false {
|
||||
didSet {
|
||||
if enabled {
|
||||
VisibilityViewManager.shared.removeView(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let onChangeStatus = EventDispatcher()
|
||||
private var isCurrentlyActiveView = false
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
}
|
||||
|
||||
public override func willMove(toWindow newWindow: UIWindow?) {
|
||||
super.willMove(toWindow: newWindow)
|
||||
|
||||
if !self.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
if newWindow == nil {
|
||||
VisibilityViewManager.shared.removeView(self)
|
||||
} else {
|
||||
VisibilityViewManager.shared.addView(self)
|
||||
}
|
||||
}
|
||||
|
||||
func setIsCurrentlyActive(isActive: Bool) {
|
||||
if isCurrentlyActiveView == isActive {
|
||||
return
|
||||
}
|
||||
self.isCurrentlyActiveView = isActive
|
||||
self.onChangeStatus([
|
||||
"isActive": isActive
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// 🚨 DANGER 🚨
|
||||
// These functions need to be called from the main thread. Xcode will warn you if you call one of them
|
||||
// off the main thread, so pay attention!
|
||||
extension UIView {
|
||||
func getPositionOnScreen() -> CGRect? {
|
||||
if let window = self.window {
|
||||
return self.convert(self.bounds, to: window)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isViewableEnough() -> Bool {
|
||||
guard let window = self.window else {
|
||||
return false
|
||||
}
|
||||
|
||||
let viewFrameOnScreen = self.convert(self.bounds, to: window)
|
||||
let screenBounds = window.bounds
|
||||
let intersection = viewFrameOnScreen.intersection(screenBounds)
|
||||
|
||||
let viewHeight = viewFrameOnScreen.height
|
||||
let intersectionHeight = intersection.height
|
||||
|
||||
return intersectionHeight >= 0.5 * viewHeight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {requireNativeModule} from 'expo-modules-core'
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoPlatformInfo')
|
||||
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
return NativeModule.getIsReducedMotionEnabled()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
throw new NotImplementedError()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {VisibilityViewProps} from './types'
|
||||
const NativeView: React.ComponentType<{
|
||||
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
|
||||
children: React.ReactNode
|
||||
enabled: Boolean
|
||||
style: StyleProp<ViewStyle>
|
||||
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoBlueskyVisibilityView')
|
||||
|
||||
export async function updateActiveViewAsync() {
|
||||
await NativeModule.updateActiveViewAsync()
|
||||
}
|
||||
|
||||
export default function VisibilityView({
|
||||
children,
|
||||
onChangeStatus: onChangeStatusOuter,
|
||||
enabled,
|
||||
}: VisibilityViewProps) {
|
||||
const onChangeStatus = React.useCallback(
|
||||
(e: {nativeEvent: {isActive: boolean}}) => {
|
||||
onChangeStatusOuter(e.nativeEvent.isActive)
|
||||
},
|
||||
[onChangeStatusOuter],
|
||||
)
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
onChangeStatus={onChangeStatus}
|
||||
enabled={enabled}
|
||||
style={{flex: 1}}>
|
||||
{children}
|
||||
</NativeView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {VisibilityViewProps} from './types'
|
||||
|
||||
export async function updateActiveViewAsync() {
|
||||
throw new NotImplementedError()
|
||||
}
|
||||
|
||||
export default function VisibilityView({children}: VisibilityViewProps) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
export interface VisibilityViewProps {
|
||||
children: React.ReactNode
|
||||
onChangeStatus: (isActive: boolean) => void
|
||||
enabled: boolean
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.12.26",
|
||||
"@atproto/api": "0.12.29",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
|
||||
@@ -207,31 +207,3 @@ index 88b3fdf..2488ebc 100644
|
||||
|
||||
const { layout, entering, exiting, sharedTransitionTag } = this.props;
|
||||
if (
|
||||
diff --git a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
|
||||
index ac9be5d..86d4605 100644
|
||||
--- a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
|
||||
+++ b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
|
||||
@@ -47,4 +47,5 @@ export { LayoutAnimationConfig } from './component/LayoutAnimationConfig';
|
||||
export { PerformanceMonitor } from './component/PerformanceMonitor';
|
||||
export { startMapper, stopMapper } from './mappers';
|
||||
export { startScreenTransition, finishScreenTransition, ScreenTransition } from './screenTransition';
|
||||
+export { isReducedMotion } from './PlatformChecker';
|
||||
//# sourceMappingURL=index.js.map
|
||||
diff --git a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
|
||||
index f01dc57..161ef22 100644
|
||||
--- a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
|
||||
+++ b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
|
||||
@@ -36,3 +36,4 @@ export type { FlatListPropsWithLayout } from './component/FlatList';
|
||||
export { startMapper, stopMapper } from './mappers';
|
||||
export { startScreenTransition, finishScreenTransition, ScreenTransition, } from './screenTransition';
|
||||
export type { AnimatedScreenTransition, GoBackGesture, ScreenTransitionConfig, } from './screenTransition';
|
||||
+export { isReducedMotion } from './PlatformChecker';
|
||||
diff --git a/node_modules/react-native-reanimated/src/reanimated2/index.ts b/node_modules/react-native-reanimated/src/reanimated2/index.ts
|
||||
index 5885fa1..a3c693f 100644
|
||||
--- a/node_modules/react-native-reanimated/src/reanimated2/index.ts
|
||||
+++ b/node_modules/react-native-reanimated/src/reanimated2/index.ts
|
||||
@@ -284,3 +284,4 @@ export type {
|
||||
GoBackGesture,
|
||||
ScreenTransitionConfig,
|
||||
} from './screenTransition';
|
||||
+export { isReducedMotion } from './PlatformChecker';
|
||||
|
||||
@@ -92,14 +92,16 @@ function getRank(seenPost: SeenPost): string {
|
||||
tier = 'a'
|
||||
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
||||
tier = 'b'
|
||||
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||
} else if (seenPost.feedContext === 'popcluster') {
|
||||
tier = 'c'
|
||||
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||
tier = 'd'
|
||||
} else if (seenPost.feedContext === 'nettop') {
|
||||
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||
tier = 'e'
|
||||
} else {
|
||||
} else if (seenPost.feedContext === 'nettop') {
|
||||
tier = 'f'
|
||||
} else {
|
||||
tier = 'g'
|
||||
}
|
||||
let score = Math.round(
|
||||
Math.log(
|
||||
@@ -131,16 +133,26 @@ function useExperimentalSuggestedUsersQuery() {
|
||||
const {currentAccount} = useSession()
|
||||
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
||||
const dids = React.useMemo(() => {
|
||||
const {likes, follows, seen} = userActionSnapshot
|
||||
const {likes, follows, followSuggestions, seen} = userActionSnapshot
|
||||
const likeDids = likes
|
||||
.map(l => new AtUri(l))
|
||||
.map(uri => uri.host)
|
||||
.filter(did => !follows.includes(did))
|
||||
let suggestedDids: string[] = []
|
||||
if (followSuggestions.length > 0) {
|
||||
suggestedDids = [
|
||||
// It's ok if these will pick the same item (weighed by its frequency)
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
]
|
||||
}
|
||||
const seenDids = seen
|
||||
.sort(sortSeenPosts)
|
||||
.map(l => new AtUri(l.uri))
|
||||
.map(uri => uri.host)
|
||||
return [...new Set([...likeDids, ...seenDids])].filter(
|
||||
return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
|
||||
did => did !== currentAccount?.did,
|
||||
)
|
||||
}, [userActionSnapshot, currentAccount])
|
||||
|
||||
@@ -122,8 +122,16 @@ export function ListHeaderDesktop({
|
||||
if (!gtTablet) return null
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
|
||||
<Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.py_sm,
|
||||
a.px_xl,
|
||||
a.gap_xs,
|
||||
a.justify_center,
|
||||
{minHeight: 50},
|
||||
]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>{title}</Text>
|
||||
{subtitle ? (
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
{subtitle}
|
||||
|
||||
@@ -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,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',
|
||||
})
|
||||
@@ -1,5 +1,13 @@
|
||||
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',
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -40,7 +40,7 @@ export function ContentHider({
|
||||
|
||||
if (!blur || (ignoreMute && isJustAMute(modui))) {
|
||||
return (
|
||||
<View testID={testID} style={[styles.outer, style]}>
|
||||
<View testID={testID} style={style}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
@@ -163,21 +163,3 @@ export function ContentHider({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {},
|
||||
cover: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
paddingVertical: 14,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 18,
|
||||
},
|
||||
showBtn: {
|
||||
marginLeft: 'auto',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
@@ -6,50 +7,125 @@ import {
|
||||
} from '@atproto/api'
|
||||
|
||||
import {isPostInLanguage} from '../../locale/helpers'
|
||||
import {FALLBACK_MARKER_POST} from './feed/home'
|
||||
import {ReasonFeedSource} from './feed/types'
|
||||
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
|
||||
export type FeedTunerFn = (
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
dryRun: boolean,
|
||||
) => FeedViewPostsSlice[]
|
||||
|
||||
type FeedSliceItem = {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
reply?: AppBskyFeedDefs.ReplyRef
|
||||
record: AppBskyFeedPost.Record
|
||||
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
isParentBlocked: boolean
|
||||
}
|
||||
|
||||
function toSliceItem(feedViewPost: FeedViewPost): FeedSliceItem {
|
||||
return {
|
||||
post: feedViewPost.post,
|
||||
reply: feedViewPost.reply,
|
||||
}
|
||||
type AuthorContext = {
|
||||
author: AppBskyActorDefs.ProfileViewBasic
|
||||
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
}
|
||||
|
||||
export class FeedViewPostsSlice {
|
||||
_reactKey: string
|
||||
_feedPost: FeedViewPost
|
||||
items: FeedSliceItem[]
|
||||
isIncompleteThread: boolean
|
||||
isFallbackMarker: boolean
|
||||
isOrphan: boolean
|
||||
rootUri: string
|
||||
|
||||
constructor(feedPost: FeedViewPost) {
|
||||
const {post, reply, reason} = feedPost
|
||||
this.items = []
|
||||
this.isIncompleteThread = false
|
||||
this.isFallbackMarker = false
|
||||
this.isOrphan = false
|
||||
if (AppBskyFeedDefs.isPostView(reply?.root)) {
|
||||
this.rootUri = reply.root.uri
|
||||
} else {
|
||||
this.rootUri = post.uri
|
||||
}
|
||||
this._feedPost = feedPost
|
||||
this._reactKey = `slice-${feedPost.post.uri}-${
|
||||
feedPost.reason?.indexedAt || feedPost.post.indexedAt
|
||||
this._reactKey = `slice-${post.uri}-${
|
||||
feedPost.reason?.indexedAt || post.indexedAt
|
||||
}`
|
||||
this.items = [toSliceItem(feedPost)]
|
||||
}
|
||||
|
||||
get uri() {
|
||||
return this._feedPost.post.uri
|
||||
}
|
||||
|
||||
get isThread() {
|
||||
return (
|
||||
this.items.length > 1 &&
|
||||
this.items.every(
|
||||
item => item.post.author.did === this.items[0].post.author.did,
|
||||
)
|
||||
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 isQuotePost() {
|
||||
@@ -90,204 +166,171 @@ export class FeedViewPostsSlice {
|
||||
return !!this.items.find(item => item.post.uri === uri)
|
||||
}
|
||||
|
||||
isNextInThread(uri: string) {
|
||||
return this.items[this.items.length - 1].post.uri === uri
|
||||
}
|
||||
|
||||
insert(item: FeedViewPost) {
|
||||
const selfReplyUri = getSelfReplyUri(item)
|
||||
const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri)
|
||||
if (i !== -1) {
|
||||
this.items.splice(i + 1, 0, item)
|
||||
} else {
|
||||
this.items.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
flattenReplyParent() {
|
||||
if (this.items[0].reply) {
|
||||
const reply = this.items[0].reply
|
||||
if (AppBskyFeedDefs.isPostView(reply.parent)) {
|
||||
this.items.splice(0, 0, {post: reply.parent})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isFollowingAllAuthors(userDid: string) {
|
||||
getAuthors(): AuthorContext {
|
||||
const feedPost = this._feedPost
|
||||
const authors = [feedPost.post.author]
|
||||
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)) {
|
||||
authors.push(feedPost.reply.parent.author)
|
||||
parentAuthor = feedPost.reply.parent.author
|
||||
}
|
||||
if (feedPost.reply.grandparentAuthor) {
|
||||
authors.push(feedPost.reply.grandparentAuthor)
|
||||
grandparentAuthor = feedPost.reply.grandparentAuthor
|
||||
}
|
||||
if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) {
|
||||
authors.push(feedPost.reply.root.author)
|
||||
rootAuthor = feedPost.reply.root.author
|
||||
}
|
||||
}
|
||||
return authors.every(a => a.did === userDid || a.viewer?.following)
|
||||
return {
|
||||
author,
|
||||
parentAuthor,
|
||||
grandparentAuthor,
|
||||
rootAuthor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FeedTuner {
|
||||
seenKeys: Set<string> = new Set()
|
||||
seenUris: Set<string> = new Set()
|
||||
seenRootUris: Set<string> = new Set()
|
||||
|
||||
constructor(public tunerFns: FeedTunerFn[]) {}
|
||||
|
||||
reset() {
|
||||
this.seenKeys.clear()
|
||||
this.seenUris.clear()
|
||||
}
|
||||
|
||||
tune(
|
||||
feed: FeedViewPost[],
|
||||
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
|
||||
{dryRun}: {dryRun: boolean} = {
|
||||
dryRun: false,
|
||||
maintainOrder: false,
|
||||
},
|
||||
): FeedViewPostsSlice[] {
|
||||
let slices: FeedViewPostsSlice[] = []
|
||||
let slices: FeedViewPostsSlice[] = feed
|
||||
.map(item => new FeedViewPostsSlice(item))
|
||||
.filter(s => s.items.length > 0 || s.isFallbackMarker)
|
||||
|
||||
// remove posts that are replies, but which don't have the parent
|
||||
// hydrated. this means the parent was either deleted or blocked
|
||||
feed = feed.filter(item => {
|
||||
if (
|
||||
AppBskyFeedPost.isRecord(item.post.record) &&
|
||||
item.post.record.reply &&
|
||||
!item.reply
|
||||
) {
|
||||
// run the custom tuners
|
||||
for (const tunerFn of this.tunerFns) {
|
||||
slices = tunerFn(this, slices.slice(), dryRun)
|
||||
}
|
||||
|
||||
slices = slices.filter(slice => {
|
||||
if (this.seenKeys.has(slice._reactKey)) {
|
||||
return false
|
||||
}
|
||||
// Some feeds, like Following, dedupe by thread, so you only see the most recent reply.
|
||||
// However, we don't want per-thread dedupe for author feeds (where we need to show every post)
|
||||
// or for feedgens (where we want to let the feed serve multiple replies if it chooses to).
|
||||
// To avoid showing the same context (root and/or parent) more than once, we do last resort
|
||||
// per-post deduplication. It hides already seen posts as long as this doesn't break the thread.
|
||||
for (let i = 0; i < slice.items.length; i++) {
|
||||
const item = slice.items[i]
|
||||
if (this.seenUris.has(item.post.uri)) {
|
||||
if (i === 0) {
|
||||
// Omit contiguous seen leading items.
|
||||
// For example, [A -> B -> C], [A -> D -> E], [A -> D -> F]
|
||||
// would turn into [A -> B -> C], [D -> E], [F].
|
||||
slice.items.splice(0, 1)
|
||||
i--
|
||||
}
|
||||
if (i === slice.items.length - 1) {
|
||||
// If the last item in the slice was already seen, omit the whole slice.
|
||||
// This means we'd miss its parents, but the user can "show more" to see them.
|
||||
// For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C]
|
||||
// would get collapsed into [A ... E -> F], with B/C/D considered seen.
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if (!dryRun) {
|
||||
this.seenUris.add(item.post.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dryRun) {
|
||||
this.seenKeys.add(slice._reactKey)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (maintainOrder) {
|
||||
slices = feed.map(item => new FeedViewPostsSlice(item))
|
||||
} else {
|
||||
// arrange the posts into thread slices
|
||||
for (let i = feed.length - 1; i >= 0; i--) {
|
||||
const item = feed[i]
|
||||
|
||||
const selfReplyUri = getSelfReplyUri(item)
|
||||
if (selfReplyUri) {
|
||||
const index = slices.findIndex(slice =>
|
||||
slice.isNextInThread(selfReplyUri),
|
||||
)
|
||||
|
||||
if (index !== -1) {
|
||||
const parent = slices[index]
|
||||
|
||||
parent.insert(item)
|
||||
|
||||
// If our slice isn't currently on the top, reinsert it to the top.
|
||||
if (index !== 0) {
|
||||
slices.splice(index, 1)
|
||||
slices.unshift(parent)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
slices.unshift(new FeedViewPostsSlice(item))
|
||||
}
|
||||
}
|
||||
|
||||
// run the custom tuners
|
||||
for (const tunerFn of this.tunerFns) {
|
||||
slices = tunerFn(this, slices.slice())
|
||||
}
|
||||
|
||||
// remove any items already "seen"
|
||||
const soonToBeSeenUris: Set<string> = new Set()
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (!slices[i].isThread && this.seenUris.has(slices[i].uri)) {
|
||||
slices.splice(i, 1)
|
||||
} else {
|
||||
for (const item of slices[i].items) {
|
||||
soonToBeSeenUris.add(item.post.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// turn non-threads with reply parents into threads
|
||||
for (const slice of slices) {
|
||||
if (!slice.isThread && !slice.reason && slice.items[0].reply) {
|
||||
const reply = slice.items[0].reply
|
||||
if (
|
||||
AppBskyFeedDefs.isPostView(reply.parent) &&
|
||||
!this.seenUris.has(reply.parent.uri) &&
|
||||
!soonToBeSeenUris.has(reply.parent.uri)
|
||||
) {
|
||||
const uri = reply.parent.uri
|
||||
slice.flattenReplyParent()
|
||||
soonToBeSeenUris.add(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
slices = slices.filter(slice => {
|
||||
if (this.seenKeys.has(slice._reactKey)) {
|
||||
return false
|
||||
}
|
||||
for (const item of slice.items) {
|
||||
this.seenUris.add(item.post.uri)
|
||||
}
|
||||
this.seenKeys.add(slice._reactKey)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (slices[i].isReply) {
|
||||
slices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (slices[i].isRepost) {
|
||||
slices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (slices[i].isQuotePost) {
|
||||
slices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static dedupReposts(
|
||||
static removeReplies(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
): FeedViewPostsSlice[] {
|
||||
// remove duplicates caused by reposts
|
||||
_dryRun: boolean,
|
||||
) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const item1 = slices[i]
|
||||
for (let j = i + 1; j < slices.length; j++) {
|
||||
const item2 = slices[j]
|
||||
if (item2.isThread) {
|
||||
// dont dedup items that are rendering in a thread as this can cause rendering errors
|
||||
continue
|
||||
}
|
||||
if (item1.containsUri(item2.items[0].post.uri)) {
|
||||
slices.splice(j, 1)
|
||||
j--
|
||||
const slice = slices[i]
|
||||
if (
|
||||
slice.isReply &&
|
||||
!slice.isRepost &&
|
||||
// This is not perfect but it's close as we can get to
|
||||
// detecting threads without having to peek ahead.
|
||||
!areSameAuthor(slice.getAuthors())
|
||||
) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeReposts(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
if (slices[i].isRepost) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeQuotePosts(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
if (slices[i].isQuotePost) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeOrphans(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
if (slices[i].isOrphan) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static dedupThreads(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
dryRun: boolean,
|
||||
): FeedViewPostsSlice[] {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const rootUri = slices[i].rootUri
|
||||
if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
} else {
|
||||
if (!dryRun) {
|
||||
tuner.seenRootUris.add(rootUri)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,15 +341,17 @@ export class FeedTuner {
|
||||
return (
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
): FeedViewPostsSlice[] => {
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const slice = slices[i]
|
||||
if (
|
||||
slice.isReply &&
|
||||
!slice.isRepost &&
|
||||
!slice.isFollowingAllAuthors(userDid)
|
||||
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
|
||||
) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
@@ -324,6 +369,7 @@ export class FeedTuner {
|
||||
return (
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
): FeedViewPostsSlice[] => {
|
||||
const candidateSlices = slices.slice()
|
||||
|
||||
@@ -332,7 +378,7 @@ export class FeedTuner {
|
||||
return slices
|
||||
}
|
||||
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
let hasPreferredLang = false
|
||||
for (const item of slices[i].items) {
|
||||
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
||||
@@ -358,16 +404,66 @@ export class FeedTuner {
|
||||
}
|
||||
}
|
||||
|
||||
function getSelfReplyUri(item: FeedViewPost): string | undefined {
|
||||
if (item.reply) {
|
||||
if (
|
||||
AppBskyFeedDefs.isPostView(item.reply.parent) &&
|
||||
!AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies
|
||||
) {
|
||||
return item.reply.parent.author.did === item.post.author.did
|
||||
? item.reply.parent.uri
|
||||
: undefined
|
||||
}
|
||||
function areSameAuthor(authors: AuthorContext): boolean {
|
||||
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||
const authorDid = author.did
|
||||
if (parentAuthor && parentAuthor.did !== authorDid) {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
if (grandparentAuthor && grandparentAuthor.did !== authorDid) {
|
||||
return false
|
||||
}
|
||||
if (rootAuthor && rootAuthor.did !== authorDid) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldDisplayReplyInFollowing(
|
||||
authors: AuthorContext,
|
||||
userDid: string,
|
||||
): boolean {
|
||||
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||
if (!isSelfOrFollowing(author, userDid)) {
|
||||
// Only show replies from self or people you follow.
|
||||
return false
|
||||
}
|
||||
if (
|
||||
(!parentAuthor || parentAuthor.did === author.did) &&
|
||||
(!rootAuthor || rootAuthor.did === author.did) &&
|
||||
(!grandparentAuthor || grandparentAuthor.did === author.did)
|
||||
) {
|
||||
// Always show self-threads.
|
||||
return true
|
||||
}
|
||||
// From this point on we need at least one more reason to show it.
|
||||
if (
|
||||
parentAuthor &&
|
||||
parentAuthor.did !== author.did &&
|
||||
isSelfOrFollowing(parentAuthor, userDid)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
grandparentAuthor &&
|
||||
grandparentAuthor.did !== author.did &&
|
||||
isSelfOrFollowing(grandparentAuthor, userDid)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
rootAuthor &&
|
||||
rootAuthor.did !== author.did &&
|
||||
isSelfOrFollowing(rootAuthor, userDid)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isSelfOrFollowing(
|
||||
profile: AppBskyActorDefs.ProfileViewBasic,
|
||||
userDid: string,
|
||||
) {
|
||||
return Boolean(profile.did === userDid || profile.viewer?.following)
|
||||
}
|
||||
|
||||
@@ -193,12 +193,6 @@ class MergeFeedSource {
|
||||
return this.hasMore && this.queue.length === 0
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
this.queue = []
|
||||
this.hasMore = true
|
||||
}
|
||||
|
||||
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
|
||||
return this.queue.splice(0, n)
|
||||
}
|
||||
@@ -232,11 +226,6 @@ class MergeFeedSource {
|
||||
class MergeFeedSource_Following extends MergeFeedSource {
|
||||
tuner = new FeedTuner(this.feedTuners)
|
||||
|
||||
reset() {
|
||||
super.reset()
|
||||
this.tuner.reset()
|
||||
}
|
||||
|
||||
async fetchNext(n: number) {
|
||||
return this._fetchNextInner(n)
|
||||
}
|
||||
@@ -249,7 +238,6 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
||||
// run the tuner pre-emptively to ensure better mixing
|
||||
const slices = this.tuner.tune(res.data.feed, {
|
||||
dryRun: false,
|
||||
maintainOrder: true,
|
||||
})
|
||||
res.data.feed = slices.map(slice => slice._feedPost)
|
||||
return res
|
||||
|
||||
@@ -134,7 +134,7 @@ export function useModerationCauseDescription(
|
||||
}
|
||||
}
|
||||
if (def.identifier === 'porn' || def.identifier === 'sexual') {
|
||||
strings.name = 'Adult Content'
|
||||
strings.name = _(msg`Adult Content`)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {BskyAgent} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {SessionAccount, useAgent, useSession} from '#/state/session'
|
||||
import {logEvent, useGate} from 'lib/statsig/statsig'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {devicePlatform, isAndroid, isNative} from 'platform/detection'
|
||||
import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'
|
||||
|
||||
@@ -86,7 +86,6 @@ export function useNotificationsRegistration() {
|
||||
}
|
||||
|
||||
export function useRequestNotificationsPermission() {
|
||||
const gate = useGate()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
@@ -102,16 +101,7 @@ export function useRequestNotificationsPermission() {
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
context === 'StartOnboarding' &&
|
||||
gate('request_notifications_permission_after_onboarding_v2')
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
context === 'AfterOnboarding' &&
|
||||
!gate('request_notifications_permission_after_onboarding_v2')
|
||||
) {
|
||||
if (context === 'AfterOnboarding') {
|
||||
return
|
||||
}
|
||||
if (context === 'Home' && !currentAccount) {
|
||||
|
||||
@@ -159,6 +159,7 @@ export type LogEvents = {
|
||||
| 'AvatarButton'
|
||||
| 'StarterPackProfilesList'
|
||||
| 'FeedInterstitial'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
}
|
||||
'profile:unfollow': {
|
||||
logContext:
|
||||
@@ -173,6 +174,7 @@ export type LogEvents = {
|
||||
| 'AvatarButton'
|
||||
| 'StarterPackProfilesList'
|
||||
| 'FeedInterstitial'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
}
|
||||
'chat:create': {
|
||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||
@@ -211,6 +213,14 @@ export type LogEvents = {
|
||||
'feed:interstitial:profileCard:press': {}
|
||||
'feed:interstitial:feedCard:press': {}
|
||||
|
||||
'profile:header:suggestedFollowsCard:press': {}
|
||||
|
||||
'debug:followingPrefs': {
|
||||
followingShowRepliesFromPref: 'all' | 'following' | 'off'
|
||||
followingRepliesMinLikePref: number
|
||||
}
|
||||
'debug:followingDisplayed': {}
|
||||
|
||||
'test:all:always': {}
|
||||
'test:all:sometimes': {}
|
||||
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'debug_show_feedcontext'
|
||||
| 'explore_page_profile_card_social_proof'
|
||||
| 'native_pwi_disabled'
|
||||
| 'new_user_guided_tour'
|
||||
| 'new_user_progress_guide'
|
||||
| 'onboarding_minimum_interests'
|
||||
| 'request_notifications_permission_after_onboarding_v2'
|
||||
| 'session_withproxy_fix'
|
||||
| 'show_avi_follow_button'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'suggested_feeds_interstitial'
|
||||
| 'suggested_follows_interstitial'
|
||||
| 'ungroup_follow_backs'
|
||||
| 'video_debug'
|
||||
| 'videos'
|
||||
| 'small_avi_thumb'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {isReducedMotion} from 'react-native-reanimated'
|
||||
import {getLocales} from 'expo-localization'
|
||||
|
||||
import {fixLegacyLanguageCode} from '#/locale/helpers'
|
||||
@@ -15,11 +14,10 @@ export const isMobileWeb =
|
||||
isWeb &&
|
||||
// @ts-ignore we know window exists -prf
|
||||
global.window.matchMedia(isMobileWebMediaQuery)?.matches
|
||||
export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent)
|
||||
|
||||
export const deviceLocales = dedupArray(
|
||||
getLocales?.()
|
||||
.map?.(locale => fixLegacyLanguageCode(locale.languageCode))
|
||||
.filter(code => typeof code === 'string'),
|
||||
) as string[]
|
||||
|
||||
export const prefersReducedMotion = isReducedMotion()
|
||||
|
||||
@@ -387,9 +387,6 @@ export function MessagesList({
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableFullWindowScroll={true}
|
||||
// Prevents wrong position in Firefox when sending a message
|
||||
// as well as scroll getting stuck on Chome when scrolling upwards.
|
||||
disableContainStyle={true}
|
||||
disableVirtualization={true}
|
||||
style={animatedListStyle}
|
||||
// The extra two items account for the header and the footer components
|
||||
|
||||
@@ -157,7 +157,7 @@ let ProfileHeaderStandard = ({
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={isPlaceholderProfile}>
|
||||
<View
|
||||
style={[a.px_lg, a.pt_md, a.pb_sm]}
|
||||
style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -79,6 +79,7 @@ export const ProfileFeedSection = React.forwardRef<
|
||||
headerOffset={headerHeight}
|
||||
renderEndOfFeed={ProfileEndOfFeed}
|
||||
ignoreFilterFor={ignoreFilterFor}
|
||||
outsideHeaderOffset={headerHeight}
|
||||
/>
|
||||
{(isScrolledDown || hasNew) && (
|
||||
<LoadLatestBtn
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import {AccessibilityInfo} from 'react-native'
|
||||
import {isReducedMotion} from 'react-native-reanimated'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {PlatformInfo} from '../../modules/expo-bluesky-swiss-army'
|
||||
|
||||
const Context = React.createContext({
|
||||
reduceMotionEnabled: false,
|
||||
@@ -15,7 +15,7 @@ export function useA11y() {
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() =>
|
||||
isReducedMotion(),
|
||||
PlatformInfo.getIsReducedMotionEnabled(),
|
||||
)
|
||||
const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false)
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
||||
toString({
|
||||
item: postItem.uri,
|
||||
event: 'app.bsky.feed.defs#interactionSeen',
|
||||
feedContext: postItem.feedContext,
|
||||
feedContext: slice.feedContext,
|
||||
}),
|
||||
)
|
||||
sendToFeed()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = persisted.Schema['invites']
|
||||
@@ -35,8 +36,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('invites'))
|
||||
return persisted.onUpdate('invites', nextInvites => {
|
||||
setState(nextInvites)
|
||||
})
|
||||
}, [setState])
|
||||
|
||||
|
||||
@@ -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 {migrate} from '#/state/persisted/legacy'
|
||||
import {defaults, Schema} from '#/state/persisted/schema'
|
||||
import * as store from '#/state/persisted/store'
|
||||
import {
|
||||
defaults,
|
||||
Schema,
|
||||
tryParse,
|
||||
tryStringify,
|
||||
} from '#/state/persisted/schema'
|
||||
import {PersistedApi} from './types'
|
||||
|
||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||
export {defaults} from '#/state/persisted/schema'
|
||||
|
||||
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
||||
const UPDATE_EVENT = 'BSKY_UPDATE'
|
||||
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||
|
||||
let _state: Schema = defaults
|
||||
const _emitter = new EventEmitter()
|
||||
|
||||
/**
|
||||
* Initializes and returns persisted data state, so that it can be passed to
|
||||
* the Provider.
|
||||
*/
|
||||
export async function init() {
|
||||
logger.debug('persisted state: initializing')
|
||||
|
||||
broadcast.onmessage = onBroadcastMessage
|
||||
|
||||
try {
|
||||
await migrate() // migrate old store
|
||||
const stored = await store.read() // check for new store
|
||||
if (!stored) {
|
||||
logger.debug('persisted state: initializing default storage')
|
||||
await store.write(defaults) // opt: init new store
|
||||
}
|
||||
_state = stored || defaults // return new store
|
||||
logger.debug('persisted state: initialized')
|
||||
} catch (e) {
|
||||
logger.error('persisted state: failed to load root state from storage', {
|
||||
message: e,
|
||||
})
|
||||
// AsyncStorage failure, but we can still continue in memory
|
||||
return defaults
|
||||
const stored = await readFromStorage()
|
||||
if (stored) {
|
||||
_state = stored
|
||||
}
|
||||
}
|
||||
init satisfies PersistedApi['init']
|
||||
|
||||
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
||||
return _state[key]
|
||||
}
|
||||
get satisfies PersistedApi['get']
|
||||
|
||||
export async function write<K extends keyof Schema>(
|
||||
key: K,
|
||||
value: Schema[K],
|
||||
): Promise<void> {
|
||||
_state = {
|
||||
..._state,
|
||||
[key]: value,
|
||||
}
|
||||
await writeToStorage(_state)
|
||||
}
|
||||
write satisfies PersistedApi['write']
|
||||
|
||||
export function onUpdate<K extends keyof Schema>(
|
||||
_key: K,
|
||||
_cb: (v: Schema[K]) => void,
|
||||
): () => void {
|
||||
return () => {}
|
||||
}
|
||||
onUpdate satisfies PersistedApi['onUpdate']
|
||||
|
||||
export async function clearStorage() {
|
||||
try {
|
||||
_state[key] = value
|
||||
await store.write(_state)
|
||||
// must happen on next tick, otherwise the tab will read stale storage data
|
||||
setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0)
|
||||
logger.debug(`persisted state: wrote root state to storage`, {
|
||||
updatedKey: key,
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(`persisted state: failed writing root state to storage`, {
|
||||
message: e,
|
||||
})
|
||||
await AsyncStorage.removeItem(BSKY_STORAGE)
|
||||
} catch (e: any) {
|
||||
logger.error(`persisted store: failed to clear`, {message: e.toString()})
|
||||
}
|
||||
}
|
||||
clearStorage satisfies PersistedApi['clearStorage']
|
||||
|
||||
export function onUpdate(cb: () => void): () => void {
|
||||
_emitter.addListener('update', cb)
|
||||
return () => _emitter.removeListener('update', cb)
|
||||
}
|
||||
|
||||
async function onBroadcastMessage({data}: MessageEvent) {
|
||||
// validate event
|
||||
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
|
||||
async function writeToStorage(value: Schema) {
|
||||
const rawData = tryStringify(value)
|
||||
if (rawData) {
|
||||
try {
|
||||
// read next state, possibly updated by another tab
|
||||
const next = await store.read()
|
||||
|
||||
if (next) {
|
||||
logger.debug(`persisted state: handling update from broadcast channel`)
|
||||
_state = next
|
||||
_emitter.emit('update')
|
||||
} else {
|
||||
logger.error(
|
||||
`persisted state: handled update update from broadcast channel, but found no data`,
|
||||
)
|
||||
}
|
||||
await AsyncStorage.setItem(BSKY_STORAGE, rawData)
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`persisted state: failed handling update from broadcast channel`,
|
||||
{
|
||||
message: e,
|
||||
},
|
||||
)
|
||||
logger.error(`persisted state: failed writing root state to storage`, {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readFromStorage(): Promise<Schema | undefined> {
|
||||
let rawData: string | null = null
|
||||
try {
|
||||
rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
||||
} catch (e) {
|
||||
logger.error(`persisted state: failed reading root state from storage`, {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
if (rawData) {
|
||||
return tryParse(rawData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {deviceLocales, prefersReducedMotion} from '#/platform/detection'
|
||||
import {logger} from '#/logger'
|
||||
import {deviceLocales} from '#/platform/detection'
|
||||
import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army'
|
||||
|
||||
const externalEmbedOptions = ['show', 'hide'] as const
|
||||
|
||||
@@ -42,7 +44,7 @@ const currentAccountSchema = accountSchema.extend({
|
||||
})
|
||||
export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
|
||||
|
||||
export const schema = z.object({
|
||||
const schema = z.object({
|
||||
colorMode: z.enum(['system', 'light', 'dark']),
|
||||
darkTheme: z.enum(['dim', 'dark']).optional(),
|
||||
session: z.object({
|
||||
@@ -89,6 +91,7 @@ export const schema = z.object({
|
||||
disableAutoplay: z.boolean().optional(),
|
||||
kawaii: z.boolean().optional(),
|
||||
hasCheckedForStarterPack: z.boolean().optional(),
|
||||
subtitlesEnabled: z.boolean().optional(),
|
||||
/** @deprecated */
|
||||
mutedThreads: z.array(z.string()),
|
||||
})
|
||||
@@ -128,7 +131,48 @@ export const defaults: Schema = {
|
||||
lastSelectedHomeFeed: undefined,
|
||||
pdsAddressHistory: [],
|
||||
disableHaptics: false,
|
||||
disableAutoplay: prefersReducedMotion,
|
||||
disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
|
||||
kawaii: false,
|
||||
hasCheckedForStarterPack: false,
|
||||
subtitlesEnabled: true,
|
||||
}
|
||||
|
||||
export function tryParse(rawData: string): Schema | undefined {
|
||||
let objData
|
||||
try {
|
||||
objData = JSON.parse(rawData)
|
||||
} catch (e) {
|
||||
logger.error('persisted state: failed to parse root state from storage', {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
if (!objData) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = schema.safeParse(objData)
|
||||
if (parsed.success) {
|
||||
return objData
|
||||
} else {
|
||||
const errors =
|
||||
parsed.error?.errors?.map(e => ({
|
||||
code: e.code,
|
||||
// @ts-ignore exists on some types
|
||||
expected: e?.expected,
|
||||
path: e.path?.join('.'),
|
||||
})) || []
|
||||
logger.error(`persisted store: data failed validation on read`, {errors})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function tryStringify(value: Schema): string | undefined {
|
||||
try {
|
||||
schema.parse(value)
|
||||
return JSON.stringify(value)
|
||||
} catch (e) {
|
||||
logger.error(`persisted state: failed stringifying root state`, {
|
||||
message: e,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('requireAltTextEnabled'))
|
||||
})
|
||||
return persisted.onUpdate(
|
||||
'requireAltTextEnabled',
|
||||
nextRequireAltTextEnabled => {
|
||||
setState(nextRequireAltTextEnabled)
|
||||
},
|
||||
)
|
||||
}, [setStateWrapped])
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(Boolean(persisted.get('disableAutoplay')))
|
||||
return persisted.onUpdate('disableAutoplay', nextDisableAutoplay => {
|
||||
setState(Boolean(nextDisableAutoplay))
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(Boolean(persisted.get('disableHaptics')))
|
||||
return persisted.onUpdate('disableHaptics', nextDisableHaptics => {
|
||||
setState(Boolean(nextDisableHaptics))
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('externalEmbeds'))
|
||||
return persisted.onUpdate('externalEmbeds', nextExternalEmbeds => {
|
||||
setState(nextExternalEmbeds)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -19,20 +19,15 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||
}
|
||||
}
|
||||
if (feedDesc.startsWith('feedgen')) {
|
||||
return [
|
||||
FeedTuner.dedupReposts,
|
||||
FeedTuner.preferredLangOnly(langPrefs.contentLanguages),
|
||||
]
|
||||
return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
|
||||
}
|
||||
if (feedDesc.startsWith('list')) {
|
||||
const feedTuners = []
|
||||
|
||||
let feedTuners = []
|
||||
if (feedDesc.endsWith('|as_following')) {
|
||||
// Same as Following tuners below, copypaste for now.
|
||||
feedTuners.push(FeedTuner.removeOrphans)
|
||||
if (preferences?.feedViewPrefs.hideReposts) {
|
||||
feedTuners.push(FeedTuner.removeReposts)
|
||||
} else {
|
||||
feedTuners.push(FeedTuner.dedupReposts)
|
||||
}
|
||||
if (preferences?.feedViewPrefs.hideReplies) {
|
||||
feedTuners.push(FeedTuner.removeReplies)
|
||||
@@ -46,18 +41,15 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
||||
}
|
||||
} else {
|
||||
feedTuners.push(FeedTuner.dedupReposts)
|
||||
feedTuners.push(FeedTuner.dedupThreads)
|
||||
}
|
||||
return feedTuners
|
||||
}
|
||||
if (feedDesc === 'following') {
|
||||
const feedTuners = []
|
||||
const feedTuners = [FeedTuner.removeOrphans]
|
||||
|
||||
if (preferences?.feedViewPrefs.hideReposts) {
|
||||
feedTuners.push(FeedTuner.removeReposts)
|
||||
} else {
|
||||
feedTuners.push(FeedTuner.dedupReposts)
|
||||
}
|
||||
if (preferences?.feedViewPrefs.hideReplies) {
|
||||
feedTuners.push(FeedTuner.removeReplies)
|
||||
@@ -71,6 +63,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
||||
}
|
||||
feedTuners.push(FeedTuner.dedupThreads)
|
||||
|
||||
return feedTuners
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('hiddenPosts'))
|
||||
return persisted.onUpdate('hiddenPosts', nextHiddenPosts => {
|
||||
setState(nextHiddenPosts)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('useInAppBrowser'))
|
||||
return persisted.onUpdate('useInAppBrowser', nextUseInAppBrowser => {
|
||||
setState(nextUseInAppBrowser)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser'
|
||||
import {Provider as KawaiiProvider} from './kawaii'
|
||||
import {Provider as LanguagesProvider} from './languages'
|
||||
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
|
||||
import {Provider as SubtitlesProvider} from './subtitles'
|
||||
import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
|
||||
|
||||
export {
|
||||
@@ -24,6 +25,7 @@ export {
|
||||
export * from './hidden-posts'
|
||||
export {useLabelDefinitions} from './label-defs'
|
||||
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
|
||||
export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles'
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
return (
|
||||
@@ -36,7 +38,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
<DisableHapticsProvider>
|
||||
<AutoplayProvider>
|
||||
<UsedStarterPacksProvider>
|
||||
<KawaiiProvider>{children}</KawaiiProvider>
|
||||
<SubtitlesProvider>
|
||||
<KawaiiProvider>{children}</KawaiiProvider>
|
||||
</SubtitlesProvider>
|
||||
</UsedStarterPacksProvider>
|
||||
</AutoplayProvider>
|
||||
</DisableHapticsProvider>
|
||||
|
||||
@@ -21,8 +21,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('kawaii'))
|
||||
return persisted.onUpdate('kawaii', nextKawaii => {
|
||||
setState(nextKawaii)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('languagePrefs'))
|
||||
return persisted.onUpdate('languagePrefs', nextLanguagePrefs => {
|
||||
setState(nextLanguagePrefs)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('largeAltBadgeEnabled'))
|
||||
})
|
||||
return persisted.onUpdate(
|
||||
'largeAltBadgeEnabled',
|
||||
nextLargeAltBadgeEnabled => {
|
||||
setState(nextLargeAltBadgeEnabled)
|
||||
},
|
||||
)
|
||||
}, [setStateWrapped])
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = boolean
|
||||
type SetContext = (v: boolean) => void
|
||||
|
||||
const stateContext = React.createContext<StateContext>(
|
||||
Boolean(persisted.defaults.subtitlesEnabled),
|
||||
)
|
||||
const setContext = React.createContext<SetContext>((_: boolean) => {})
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [state, setState] = React.useState(
|
||||
Boolean(persisted.get('subtitlesEnabled')),
|
||||
)
|
||||
|
||||
const setStateWrapped = React.useCallback(
|
||||
(subtitlesEnabled: persisted.Schema['subtitlesEnabled']) => {
|
||||
setState(Boolean(subtitlesEnabled))
|
||||
persisted.write('subtitlesEnabled', subtitlesEnabled)
|
||||
},
|
||||
[setState],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate('subtitlesEnabled', nextSubtitlesEnabled => {
|
||||
setState(Boolean(nextSubtitlesEnabled))
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
return (
|
||||
<stateContext.Provider value={state}>
|
||||
<setContext.Provider value={setStateWrapped}>
|
||||
{children}
|
||||
</setContext.Provider>
|
||||
</stateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useSubtitlesEnabled = () => React.useContext(stateContext)
|
||||
export const useSetSubtitlesEnabled = () => React.useContext(setContext)
|
||||
@@ -19,9 +19,12 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('hasCheckedForStarterPack'))
|
||||
})
|
||||
return persisted.onUpdate(
|
||||
'hasCheckedForStarterPack',
|
||||
nextHasCheckedForStarterPack => {
|
||||
setState(nextHasCheckedForStarterPack)
|
||||
},
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useModerationOpts} from '../../preferences/moderation-opts'
|
||||
import {STALE} from '..'
|
||||
@@ -59,7 +58,6 @@ export function useNotificationFeedQuery(opts?: {
|
||||
const moderationOpts = useModerationOpts()
|
||||
const unreads = useUnreadNotificationsApi()
|
||||
const enabled = opts?.enabled !== false
|
||||
const gate = useGate()
|
||||
|
||||
// false: force showing all notifications
|
||||
// undefined: let the server decide
|
||||
@@ -88,7 +86,6 @@ export function useNotificationFeedQuery(opts?: {
|
||||
queryClient,
|
||||
moderationOpts,
|
||||
fetchAdditionalData: true,
|
||||
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
||||
priority,
|
||||
})
|
||||
page = fetchedPage
|
||||
|
||||
@@ -8,7 +8,6 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
import BroadcastChannel from '#/lib/broadcast'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {resetBadgeCount} from 'lib/notifications/notifications'
|
||||
@@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const gate = useGate()
|
||||
|
||||
const [numUnread, setNumUnread] = React.useState('')
|
||||
|
||||
@@ -151,7 +149,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
// only fetch subjects when the page is going to be used
|
||||
// in the notifications query, otherwise skip it
|
||||
fetchAdditionalData: !!invalidate,
|
||||
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
||||
})
|
||||
const unreadCount = countUnread(page)
|
||||
const unreadCountStr =
|
||||
@@ -192,7 +189,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [setNumUnread, queryClient, moderationOpts, agent, gate])
|
||||
}, [setNumUnread, queryClient, moderationOpts, agent])
|
||||
checkUnreadRef.current = api.checkUnread
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,7 +30,6 @@ export async function fetchPage({
|
||||
queryClient,
|
||||
moderationOpts,
|
||||
fetchAdditionalData,
|
||||
shouldUngroupFollowBacks,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
cursor: string | undefined
|
||||
@@ -38,7 +37,6 @@ export async function fetchPage({
|
||||
queryClient: QueryClient
|
||||
moderationOpts: ModerationOpts | undefined
|
||||
fetchAdditionalData: boolean
|
||||
shouldUngroupFollowBacks?: () => boolean
|
||||
priority?: boolean
|
||||
}): Promise<{
|
||||
page: FeedPage
|
||||
@@ -58,7 +56,7 @@ export async function fetchPage({
|
||||
)
|
||||
|
||||
// group notifications which are essentially similar (follows, likes on a post)
|
||||
let notifsGrouped = groupNotifications(notifs, {shouldUngroupFollowBacks})
|
||||
let notifsGrouped = groupNotifications(notifs)
|
||||
|
||||
// we fetch subjects of notifications (usually posts) now instead of lazily
|
||||
// in the UI to avoid relayouts
|
||||
@@ -117,7 +115,6 @@ export function shouldFilterNotif(
|
||||
|
||||
export function groupNotifications(
|
||||
notifs: AppBskyNotificationListNotifications.Notification[],
|
||||
options?: {shouldUngroupFollowBacks?: () => boolean},
|
||||
): FeedNotification[] {
|
||||
const groupedNotifs: FeedNotification[] = []
|
||||
for (const notif of notifs) {
|
||||
@@ -137,9 +134,7 @@ export function groupNotifications(
|
||||
const prevIsFollowBack =
|
||||
groupedNotif.notification.reason === 'follow' &&
|
||||
groupedNotif.notification.author.viewer?.following
|
||||
const shouldUngroup =
|
||||
(nextIsFollowBack || prevIsFollowBack) &&
|
||||
options?.shouldUngroupFollowBacks?.()
|
||||
const shouldUngroup = nextIsFollowBack || prevIsFollowBack
|
||||
if (!shouldUngroup) {
|
||||
groupedNotif.additional = groupedNotif.additional || []
|
||||
groupedNotif.additional.push(notif)
|
||||
|
||||
@@ -77,11 +77,6 @@ export interface FeedPostSliceItem {
|
||||
uri: string
|
||||
post: AppBskyFeedDefs.PostView
|
||||
record: AppBskyFeedPost.Record
|
||||
reason?:
|
||||
| AppBskyFeedDefs.ReasonRepost
|
||||
| ReasonFeedSource
|
||||
| {[k: string]: unknown; $type: string}
|
||||
feedContext: string | undefined
|
||||
moderation: ModerationDecision
|
||||
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
||||
isParentBlocked?: boolean
|
||||
@@ -90,9 +85,14 @@ export interface FeedPostSliceItem {
|
||||
export interface FeedPostSlice {
|
||||
_isFeedPostSlice: boolean
|
||||
_reactKey: string
|
||||
rootUri: string
|
||||
isThread: boolean
|
||||
items: FeedPostSliceItem[]
|
||||
isIncompleteThread: boolean
|
||||
isFallbackMarker: boolean
|
||||
feedContext: string | undefined
|
||||
reason?:
|
||||
| AppBskyFeedDefs.ReasonRepost
|
||||
| ReasonFeedSource
|
||||
| {[k: string]: unknown; $type: string}
|
||||
}
|
||||
|
||||
export interface FeedPageUnselected {
|
||||
@@ -313,53 +313,22 @@ export function usePostFeedQuery(
|
||||
const feedPostSlice: FeedPostSlice = {
|
||||
_reactKey: slice._reactKey,
|
||||
_isFeedPostSlice: true,
|
||||
rootUri: slice.uri,
|
||||
isThread:
|
||||
slice.items.length > 1 &&
|
||||
slice.items.every(
|
||||
item =>
|
||||
item.post.author.did ===
|
||||
slice.items[0].post.author.did,
|
||||
),
|
||||
items: slice.items
|
||||
.map((item, i) => {
|
||||
if (
|
||||
AppBskyFeedPost.isRecord(item.post.record) &&
|
||||
AppBskyFeedPost.validateRecord(item.post.record)
|
||||
.success
|
||||
) {
|
||||
const parent = item.reply?.parent
|
||||
let parentAuthor:
|
||||
| AppBskyActorDefs.ProfileViewBasic
|
||||
| undefined
|
||||
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||
parentAuthor = parent.author
|
||||
}
|
||||
if (!parentAuthor) {
|
||||
parentAuthor =
|
||||
slice.items[i + 1]?.reply?.grandparentAuthor
|
||||
}
|
||||
const replyRef = item.reply
|
||||
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
|
||||
replyRef?.parent,
|
||||
)
|
||||
|
||||
const feedPostSliceItem: FeedPostSliceItem = {
|
||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||
uri: item.post.uri,
|
||||
post: item.post,
|
||||
record: item.post.record,
|
||||
reason: slice.reason,
|
||||
feedContext: slice.feedContext,
|
||||
moderation: moderations[i],
|
||||
parentAuthor,
|
||||
isParentBlocked,
|
||||
}
|
||||
return feedPostSliceItem
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
.filter(n => !!n),
|
||||
isIncompleteThread: slice.isIncompleteThread,
|
||||
isFallbackMarker: slice.isFallbackMarker,
|
||||
feedContext: slice.feedContext,
|
||||
reason: slice.reason,
|
||||
items: slice.items.map((item, i) => {
|
||||
const feedPostSliceItem: FeedPostSliceItem = {
|
||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||
uri: item.post.uri,
|
||||
post: item.post,
|
||||
record: item.record,
|
||||
moderation: moderations[i],
|
||||
parentAuthor: item.parentAuthor,
|
||||
isParentBlocked: item.isParentBlocked,
|
||||
}
|
||||
return feedPostSliceItem
|
||||
}),
|
||||
}
|
||||
return feedPostSlice
|
||||
})
|
||||
@@ -442,7 +411,6 @@ export async function pollLatest(page: FeedPage | undefined) {
|
||||
if (post) {
|
||||
const slices = page.tuner.tune([post], {
|
||||
dryRun: true,
|
||||
maintainOrder: true,
|
||||
})
|
||||
if (slices[0]) {
|
||||
return true
|
||||
|
||||
@@ -137,6 +137,8 @@ export function sortThread(
|
||||
node: ThreadNode,
|
||||
opts: UsePreferencesQueryResponse['threadViewPrefs'],
|
||||
modCache: ThreadModerationCache,
|
||||
currentDid: string | undefined,
|
||||
justPostedUris: Set<string>,
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record,
|
||||
): ThreadNode {
|
||||
if (node.type !== 'post') {
|
||||
@@ -164,10 +166,20 @@ export function sortThread(
|
||||
return -1
|
||||
}
|
||||
|
||||
/*
|
||||
* Here, OP is actually whatever node is highlighted in the thread view,
|
||||
* NOT necessarily the root post of the thread, though it can be.
|
||||
*/
|
||||
if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) {
|
||||
const aIsJustPosted =
|
||||
a.post.author.did === currentDid && justPostedUris.has(a.post.uri)
|
||||
const bIsJustPosted =
|
||||
b.post.author.did === currentDid && justPostedUris.has(b.post.uri)
|
||||
if (aIsJustPosted && bIsJustPosted) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsJustPosted) {
|
||||
return -1 // reply while onscreen
|
||||
} else if (bIsJustPosted) {
|
||||
return 1 // reply while onscreen
|
||||
}
|
||||
}
|
||||
|
||||
const aIsByOp = a.post.author.did === node.post?.author.did
|
||||
const bIsByOp = b.post.author.did === node.post?.author.did
|
||||
if (aIsByOp && bIsByOp) {
|
||||
@@ -178,6 +190,16 @@ export function sortThread(
|
||||
return 1 // op's own reply
|
||||
}
|
||||
|
||||
const aIsBySelf = a.post.author.did === currentDid
|
||||
const bIsBySelf = b.post.author.did === currentDid
|
||||
if (aIsBySelf && bIsBySelf) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsBySelf) {
|
||||
return -1 // current account's reply
|
||||
} else if (bIsBySelf) {
|
||||
return 1 // current account's reply
|
||||
}
|
||||
|
||||
const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
|
||||
const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
|
||||
if (aBlur !== bBlur) {
|
||||
@@ -215,7 +237,14 @@ export function sortThread(
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
||||
})
|
||||
node.replies.forEach(reply =>
|
||||
sortThread(reply, opts, modCache, threadgateRecord),
|
||||
sortThread(
|
||||
reply,
|
||||
opts,
|
||||
modCache,
|
||||
currentDid,
|
||||
justPostedUris,
|
||||
threadgateRecord,
|
||||
),
|
||||
)
|
||||
}
|
||||
return node
|
||||
|
||||
@@ -40,18 +40,10 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
|
||||
pages: data.pages.map(page => {
|
||||
return {
|
||||
...page,
|
||||
lists: page.lists
|
||||
/*
|
||||
* Starter packs use a reference list, which we do not want to
|
||||
* show on profiles. At some point we could probably just filter
|
||||
* this out on the backend instead of in the client.
|
||||
*/
|
||||
.filter(l => l.purpose !== 'app.bsky.graph.defs#referencelist')
|
||||
// filter by labels
|
||||
.filter(list => {
|
||||
const decision = moderateUserList(list, moderationOpts!)
|
||||
return !decision.ui('contentList').filter
|
||||
}),
|
||||
lists: page.lists.filter(list => {
|
||||
const decision = moderateUserList(list, moderationOpts!)
|
||||
return !decision.ui('contentList').filter
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -222,6 +222,7 @@ export function useProfileFollowMutationQueue(
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext'],
|
||||
) {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const did = profile.did
|
||||
const initialFollowingUri = profile.viewer?.following
|
||||
@@ -253,6 +254,20 @@ export function useProfileFollowMutationQueue(
|
||||
updateProfileShadow(queryClient, did, {
|
||||
followingUri: finalFollowingUri,
|
||||
})
|
||||
|
||||
if (finalFollowingUri) {
|
||||
agent.app.bsky.graph
|
||||
.getSuggestedFollowsByActor({
|
||||
actor: did,
|
||||
})
|
||||
.then(res => {
|
||||
const dids = res.data.suggestions
|
||||
.filter(a => !a.viewer?.following)
|
||||
.map(a => a.did)
|
||||
.slice(0, 8)
|
||||
userActionHistory.followSuggestion(dids)
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
||||
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
|
||||
const agent = useAgent()
|
||||
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
|
||||
gcTime: 0,
|
||||
queryKey: suggestedFollowsByActorQueryKey(did),
|
||||
queryFn: async () => {
|
||||
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
|
||||
|
||||
@@ -2,10 +2,11 @@ import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {CompressedVideo} from 'lib/media/video/compress'
|
||||
import {UploadVideoResponse} from 'lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from 'state/queries/video/util'
|
||||
import {useSession} from 'state/session'
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {UploadVideoResponse} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
|
||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
@@ -18,6 +19,7 @@ export const useUploadVideoMutation = ({
|
||||
setProgress: (progress: number) => void
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (video: CompressedVideo) => {
|
||||
@@ -26,6 +28,17 @@ export const useUploadVideoMutation = ({
|
||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
||||
})
|
||||
|
||||
// a logged-in agent should have this set, but we'll check just in case
|
||||
if (!agent.pdsUrl) {
|
||||
throw new Error('Agent does not have a PDS URL')
|
||||
}
|
||||
|
||||
const {data: serviceAuth} =
|
||||
await agent.api.com.atproto.server.getServiceAuth({
|
||||
aud: `did:web:${agent.pdsUrl.hostname}`,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
})
|
||||
|
||||
const uploadTask = createUploadTask(
|
||||
uri,
|
||||
video.uri,
|
||||
@@ -33,13 +46,12 @@ export const useUploadVideoMutation = ({
|
||||
headers: {
|
||||
'dev-key': UPLOAD_HEADER,
|
||||
'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4?
|
||||
Authorization: `Bearer ${serviceAuth.token}`,
|
||||
},
|
||||
httpMethod: 'POST',
|
||||
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||
},
|
||||
p => {
|
||||
setProgress(p.totalBytesSent / p.totalBytesExpectedToSend)
|
||||
},
|
||||
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
|
||||
)
|
||||
const res = await uploadTask.uploadAsync()
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {CompressedVideo} from 'lib/media/video/compress'
|
||||
import {UploadVideoResponse} from 'lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from 'state/queries/video/util'
|
||||
import {useSession} from 'state/session'
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {UploadVideoResponse} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
|
||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
@@ -17,6 +18,7 @@ export const useUploadVideoMutation = ({
|
||||
setProgress: (progress: number) => void
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (video: CompressedVideo) => {
|
||||
@@ -25,6 +27,17 @@ export const useUploadVideoMutation = ({
|
||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
||||
})
|
||||
|
||||
// a logged-in agent should have this set, but we'll check just in case
|
||||
if (!agent.pdsUrl) {
|
||||
throw new Error('Agent does not have a PDS URL')
|
||||
}
|
||||
|
||||
const {data: serviceAuth} =
|
||||
await agent.api.com.atproto.server.getServiceAuth({
|
||||
aud: `did:web:${agent.pdsUrl.hostname}`,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
})
|
||||
|
||||
const bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
@@ -53,6 +66,7 @@ export const useUploadVideoMutation = ({
|
||||
xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type?
|
||||
// @TODO remove this header for prod
|
||||
xhr.setRequestHeader('dev-key', UPLOAD_HEADER)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
||||
xhr.send(bytes)
|
||||
})) as UploadVideoResponse
|
||||
|
||||
|
||||
@@ -185,8 +185,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}, [state])
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
const synced = persisted.get('session')
|
||||
return persisted.onUpdate('session', nextSession => {
|
||||
const synced = nextSession
|
||||
addSessionDebugLog({type: 'persisted:receive', data: synced})
|
||||
dispatch({
|
||||
type: 'synced-accounts',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = {
|
||||
@@ -43,10 +44,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setColorMode(persisted.get('colorMode'))
|
||||
setDarkTheme(persisted.get('darkTheme'))
|
||||
const unsub1 = persisted.onUpdate('darkTheme', nextDarkTheme => {
|
||||
setDarkTheme(nextDarkTheme)
|
||||
})
|
||||
const unsub2 = persisted.onUpdate('colorMode', nextColorMode => {
|
||||
setColorMode(nextColorMode)
|
||||
})
|
||||
return () => {
|
||||
unsub1()
|
||||
unsub2()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyRichtextFacet,
|
||||
ModerationDecision,
|
||||
AppBskyActorDefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
|
||||
export interface ComposerOptsPostRef {
|
||||
@@ -31,7 +32,7 @@ export interface ComposerOptsQuote {
|
||||
}
|
||||
export interface ComposerOpts {
|
||||
replyTo?: ComposerOptsPostRef
|
||||
onPost?: () => void
|
||||
onPost?: (postUri: string | undefined) => void
|
||||
quote?: ComposerOptsQuote
|
||||
mention?: string // handle of user to mention
|
||||
openPicker?: (pos: DOMRect | undefined) => void
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
export const OnboardingScreenSteps = {
|
||||
Welcome: 'Welcome',
|
||||
@@ -81,13 +82,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
const next = persisted.get('onboarding').step
|
||||
return persisted.onUpdate('onboarding', nextOnboarding => {
|
||||
const next = nextOnboarding.step
|
||||
// TODO we've introduced a footgun
|
||||
if (state.step !== next) {
|
||||
dispatch({
|
||||
type: 'set',
|
||||
step: persisted.get('onboarding').step as OnboardingStep,
|
||||
step: nextOnboarding.step as OnboardingStep,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {
|
||||
ProgressGuideToast,
|
||||
ProgressGuideToastRef,
|
||||
@@ -61,7 +60,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutateAsync, variables, isPending} =
|
||||
useSetActiveProgressGuideMutation()
|
||||
const gate = useGate()
|
||||
|
||||
const activeProgressGuide = (
|
||||
isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
|
||||
@@ -89,9 +87,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const controls = React.useMemo(() => {
|
||||
return {
|
||||
startProgressGuide(guide: ProgressGuideName) {
|
||||
if (!gate('new_user_progress_guide')) {
|
||||
return
|
||||
}
|
||||
if (guide === 'like-10-and-follow-7') {
|
||||
const guideObj = {
|
||||
guide: 'like-10-and-follow-7',
|
||||
@@ -148,7 +143,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
mutateAsync(guide?.isComplete ? undefined : guide)
|
||||
},
|
||||
}
|
||||
}, [activeProgressGuide, mutateAsync, gate, setLocalGuideState])
|
||||
}, [activeProgressGuide, mutateAsync, setLocalGuideState])
|
||||
|
||||
return (
|
||||
<ProgressGuideContext.Provider value={localGuideState}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
|
||||
const LIKE_WINDOW = 100
|
||||
const FOLLOW_WINDOW = 100
|
||||
const FOLLOW_SUGGESTION_WINDOW = 100
|
||||
const SEEN_WINDOW = 100
|
||||
|
||||
export type SeenPost = {
|
||||
@@ -22,6 +23,10 @@ export type UserActionHistory = {
|
||||
* The last 100 DIDs the user has followed
|
||||
*/
|
||||
follows: string[]
|
||||
/*
|
||||
* The last 100 DIDs of suggested follows based on last follows
|
||||
*/
|
||||
followSuggestions: string[]
|
||||
/**
|
||||
* The last 100 post URIs the user has seen from the Discover feed only
|
||||
*/
|
||||
@@ -31,6 +36,7 @@ export type UserActionHistory = {
|
||||
const userActionHistory: UserActionHistory = {
|
||||
likes: [],
|
||||
follows: [],
|
||||
followSuggestions: [],
|
||||
seen: [],
|
||||
}
|
||||
|
||||
@@ -58,6 +64,13 @@ export function follow(dids: string[]) {
|
||||
.concat(dids)
|
||||
.slice(-FOLLOW_WINDOW)
|
||||
}
|
||||
|
||||
export function followSuggestion(dids: string[]) {
|
||||
userActionHistory.followSuggestions = userActionHistory.followSuggestions
|
||||
.concat(dids)
|
||||
.slice(-FOLLOW_SUGGESTION_WINDOW)
|
||||
}
|
||||
|
||||
export function unfollow(dids: string[]) {
|
||||
userActionHistory.follows = userActionHistory.follows.filter(
|
||||
uri => !dids.includes(uri),
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {s} from '#/lib/styles'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {
|
||||
useLoggedOutView,
|
||||
useLoggedOutViewControls,
|
||||
} from '#/state/shell/logged-out'
|
||||
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {Login} from '#/screens/Login'
|
||||
import {Signup} from '#/screens/Signup'
|
||||
import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
|
||||
@@ -34,7 +29,6 @@ enum ScreenState {
|
||||
export {ScreenState as LoggedOutScreenState}
|
||||
|
||||
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
const {hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
@@ -52,10 +46,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
}
|
||||
})
|
||||
const {clearRequestedAccount} = useLoggedOutViewControls()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const gate = useGate()
|
||||
|
||||
const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount
|
||||
React.useEffect(() => {
|
||||
screen('Login')
|
||||
setMinimalShellMode(true)
|
||||
@@ -68,10 +59,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
clearRequestedAccount()
|
||||
}, [clearRequestedAccount, onDismiss])
|
||||
|
||||
const onPressSearch = React.useCallback(() => {
|
||||
navigation.navigate(`SearchTab`)
|
||||
}, [navigation])
|
||||
|
||||
return (
|
||||
<View testID="noSessionView" style={[s.hContentRegion, pal.view]}>
|
||||
<ErrorBoundary>
|
||||
@@ -98,39 +85,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
) : isNative &&
|
||||
!hasSession &&
|
||||
isFirstScreen &&
|
||||
!gate('native_pwi_disabled') ? (
|
||||
<Pressable
|
||||
accessibilityHint={_(msg`Search for users`)}
|
||||
accessibilityLabel={_(msg`Search for users`)}
|
||||
accessibilityRole="button"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
right: 20,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
zIndex: 100,
|
||||
backgroundColor: pal.btn.backgroundColor,
|
||||
borderRadius: 100,
|
||||
}}
|
||||
onPress={onPressSearch}>
|
||||
<Text type="lg-bold" style={[pal.text]}>
|
||||
<Trans>Search</Trans>{' '}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="search"
|
||||
size={16}
|
||||
style={{
|
||||
color: String(pal.text.color),
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{screenState === ScreenState.S_StarterPack ? (
|
||||
|
||||
@@ -392,7 +392,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
emitPostCreated()
|
||||
}
|
||||
setLangPrefs.savePostLanguageToHistory()
|
||||
onPost?.()
|
||||
onPost?.(postUri)
|
||||
onClose()
|
||||
Toast.show(
|
||||
replyTo
|
||||
|
||||
@@ -91,7 +91,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={styles.replyToPost}>
|
||||
<Text type="xl-medium" style={t.atoms.text}>
|
||||
<Text type="xl-medium" style={t.atoms.text} numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
|
||||
)}
|
||||
|
||||
@@ -194,6 +194,7 @@ export function Feed({
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
sideBorders={false}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -25,7 +25,6 @@ import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||
import {logger} from '#/logger'
|
||||
import {FeedNotification} from '#/state/queries/notifications/feed'
|
||||
@@ -87,7 +86,6 @@ let FeedItem = ({
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const gate = useGate()
|
||||
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
|
||||
const itemHref = useMemo(() => {
|
||||
if (item.type === 'post-like' || item.type === 'repost') {
|
||||
@@ -207,7 +205,7 @@ let FeedItem = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (isFollowBack && gate('ungroup_follow_backs')) {
|
||||
if (isFollowBack) {
|
||||
action = _(msg`followed you back`)
|
||||
} else {
|
||||
action = _(msg`followed you`)
|
||||
@@ -255,6 +253,7 @@ let FeedItem = ({
|
||||
borderColor: pal.colors.unreadNotifBorder,
|
||||
},
|
||||
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
|
||||
a.overflow_hidden,
|
||||
]}
|
||||
href={itemHref}
|
||||
noFeedback
|
||||
@@ -547,7 +546,7 @@ function ExpandedAuthorsList({
|
||||
}, [heightInterp, visible])
|
||||
|
||||
return (
|
||||
<Animated.View style={[heightStyle, styles.overflowHidden]}>
|
||||
<Animated.View style={[a.overflow_hidden, heightStyle]}>
|
||||
{visible &&
|
||||
authors.map(author => (
|
||||
<NewLink
|
||||
@@ -643,9 +642,6 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overflowHidden: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
pointer: isWeb
|
||||
? {
|
||||
// @ts-ignore web only
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import React, {useRef} from 'react'
|
||||
import {StyleSheet, useWindowDimensions, View} from 'react-native'
|
||||
import {runOnJS} from 'react-native-reanimated'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
@@ -10,6 +12,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
@@ -26,7 +29,9 @@ import {
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useThreadgateRecordQuery} from '#/state/queries/threadgate'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
|
||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
@@ -35,9 +40,9 @@ import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ComposePrompt} from '../composer/Prompt'
|
||||
import {List, ListMethods} from '../util/List'
|
||||
import {ViewHeader} from '../util/ViewHeader'
|
||||
import {PostThreadComposePrompt} from './PostThreadComposePrompt'
|
||||
import {PostThreadItem} from './PostThreadItem'
|
||||
import {PostThreadLoadMore} from './PostThreadLoadMore'
|
||||
import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
|
||||
@@ -85,16 +90,8 @@ const keyExtractor = (item: RowItem) => {
|
||||
return item._reactKey
|
||||
}
|
||||
|
||||
export function PostThread({
|
||||
uri,
|
||||
onCanReply,
|
||||
onPressReply,
|
||||
}: {
|
||||
uri: string | undefined
|
||||
onCanReply: (canReply: boolean) => void
|
||||
onPressReply: () => unknown
|
||||
}) {
|
||||
const {hasSession} = useSession()
|
||||
export function PostThread({uri}: {uri: string | undefined}) {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
|
||||
@@ -169,6 +166,7 @@ export function PostThread({
|
||||
// On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
|
||||
const [deferParents, setDeferParents] = React.useState(isNative)
|
||||
|
||||
const currentDid = currentAccount?.did
|
||||
const threadModerationCache = React.useMemo(() => {
|
||||
const cache: ThreadModerationCache = new WeakMap()
|
||||
if (thread && moderationOpts) {
|
||||
@@ -177,6 +175,10 @@ export function PostThread({
|
||||
return cache
|
||||
}, [thread, moderationOpts])
|
||||
|
||||
const [justPostedUris, setJustPostedUris] = React.useState(
|
||||
() => new Set<string>(),
|
||||
)
|
||||
|
||||
const skeleton = React.useMemo(() => {
|
||||
const threadViewPrefs = preferences?.threadViewPrefs
|
||||
if (!threadViewPrefs || !thread) return null
|
||||
@@ -186,21 +188,24 @@ export function PostThread({
|
||||
thread,
|
||||
threadViewPrefs,
|
||||
threadModerationCache,
|
||||
currentDid,
|
||||
justPostedUris,
|
||||
threadgateRecord ?? undefined,
|
||||
),
|
||||
hasSession,
|
||||
!!currentDid,
|
||||
treeView,
|
||||
threadModerationCache,
|
||||
hiddenRepliesState !== HiddenRepliesState.Hide,
|
||||
threadgateRecord || undefined,
|
||||
threadgateRecord ?? undefined,
|
||||
)
|
||||
}, [
|
||||
thread,
|
||||
preferences?.threadViewPrefs,
|
||||
hasSession,
|
||||
currentDid,
|
||||
treeView,
|
||||
threadModerationCache,
|
||||
hiddenRepliesState,
|
||||
justPostedUris,
|
||||
threadgateRecord,
|
||||
])
|
||||
|
||||
@@ -231,14 +236,6 @@ export function PostThread({
|
||||
return null
|
||||
}, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
onCanReply(false)
|
||||
} else if (rootPost) {
|
||||
onCanReply(!rootPost.viewer?.replyDisabled)
|
||||
}
|
||||
}, [rootPost, onCanReply, error])
|
||||
|
||||
// construct content
|
||||
const posts = React.useMemo(() => {
|
||||
if (!skeleton) return []
|
||||
@@ -334,6 +331,38 @@ export function PostThread({
|
||||
setMaxReplies(prev => prev + 50)
|
||||
}, [isFetching, maxReplies, posts.length])
|
||||
|
||||
const onPostReply = React.useCallback(
|
||||
(postUri: string | undefined) => {
|
||||
refetch()
|
||||
if (postUri) {
|
||||
setJustPostedUris(set => {
|
||||
const nextSet = new Set(set)
|
||||
nextSet.add(postUri)
|
||||
return nextSet
|
||||
})
|
||||
}
|
||||
},
|
||||
[refetch],
|
||||
)
|
||||
|
||||
const {openComposer} = useComposerControls()
|
||||
const onPressReply = React.useCallback(() => {
|
||||
if (thread?.type !== 'post') {
|
||||
return
|
||||
}
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: thread.post.uri,
|
||||
cid: thread.post.cid,
|
||||
text: thread.record.text,
|
||||
author: thread.post.author,
|
||||
embed: thread.post.embed,
|
||||
},
|
||||
onPost: onPostReply,
|
||||
})
|
||||
}, [openComposer, thread, onPostReply])
|
||||
|
||||
const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled
|
||||
const hasParents =
|
||||
skeleton?.highlightedPost?.type === 'post' &&
|
||||
(skeleton.highlightedPost.ctx.isParentLoading ||
|
||||
@@ -345,7 +374,9 @@ export function PostThread({
|
||||
if (item === REPLY_PROMPT && hasSession) {
|
||||
return (
|
||||
<View>
|
||||
{!isMobile && <ComposePrompt onPressCompose={onPressReply} />}
|
||||
{!isMobile && (
|
||||
<PostThreadComposePrompt onPressCompose={onPressReply} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
} else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
|
||||
@@ -427,7 +458,7 @@ export function PostThread({
|
||||
HiddenRepliesState.ShowAndOverridePostHider &&
|
||||
item.ctx.depth > 0
|
||||
}
|
||||
onPostReply={refetch}
|
||||
onPostReply={onPostReply}
|
||||
hideTopBorder={index === 0 && !item.ctx.isParentLoading}
|
||||
/>
|
||||
</View>
|
||||
@@ -494,10 +525,30 @@ export function PostThread({
|
||||
sideBorders={false}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
{isMobile && canReply && hasSession && (
|
||||
<MobileComposePrompt onPressReply={onPressReply} />
|
||||
)}
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
|
||||
const safeAreaInsets = useSafeAreaInsets()
|
||||
const fabMinimalShellTransform = useMinimalShellFabTransform()
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.prompt,
|
||||
fabMinimalShellTransform,
|
||||
{
|
||||
bottom: clamp(safeAreaInsets.bottom, 15, 30),
|
||||
},
|
||||
]}>
|
||||
<PostThreadComposePrompt onPressCompose={onPressReply} />
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function isThreadPost(v: unknown): v is ThreadPost {
|
||||
return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
|
||||
}
|
||||
@@ -516,7 +567,7 @@ function createThreadSkeleton(
|
||||
treeView: boolean,
|
||||
modCache: ThreadModerationCache,
|
||||
showHiddenReplies: boolean,
|
||||
threadgateRecord: AppBskyFeedThreadgate.Record | undefined,
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record,
|
||||
): ThreadSkeletonParts | null {
|
||||
if (!node) return null
|
||||
|
||||
@@ -567,7 +618,7 @@ function* flattenThreadReplies(
|
||||
treeView: boolean,
|
||||
modCache: ThreadModerationCache,
|
||||
showHiddenReplies: boolean,
|
||||
threadgateRecord: AppBskyFeedThreadgate.Record | undefined,
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record,
|
||||
): Generator<YieldedItem, HiddenReplyType> {
|
||||
if (node.type === 'post') {
|
||||
// dont show pwi-opted-out posts to logged out users
|
||||
@@ -654,3 +705,12 @@ function hasBranchingReplies(node?: ThreadNode) {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
prompt: {
|
||||
// @ts-ignore web-only
|
||||
position: isWeb ? 'fixed' : 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -10,7 +10,11 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
|
||||
export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
|
||||
export function PostThreadComposePrompt({
|
||||
onPressCompose,
|
||||
}: {
|
||||
onPressCompose: () => void
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const {data: profile} = useProfileQuery({did: currentAccount?.did})
|
||||
const pal = usePalette('default')
|
||||
@@ -77,7 +77,7 @@ export function PostThreadItem({
|
||||
showParentReplyLine?: boolean
|
||||
hasPrecedingItem: boolean
|
||||
overrideBlur: boolean
|
||||
onPostReply: () => void
|
||||
onPostReply: (postUri: string | undefined) => void
|
||||
hideTopBorder?: boolean
|
||||
}) {
|
||||
const postShadowed = usePostShadow(post)
|
||||
@@ -171,7 +171,7 @@ let PostThreadItemLoaded = ({
|
||||
showParentReplyLine?: boolean
|
||||
hasPrecedingItem: boolean
|
||||
overrideBlur: boolean
|
||||
onPostReply: () => void
|
||||
onPostReply: (postUri: string | undefined) => void
|
||||
hideTopBorder?: boolean
|
||||
}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
@@ -765,6 +765,7 @@ const styles = StyleSheet.create({
|
||||
flexWrap: 'wrap',
|
||||
paddingBottom: 4,
|
||||
paddingRight: 10,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
postTextLargeContainer: {
|
||||
paddingHorizontal: 0,
|
||||
|
||||