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)
  ...
This commit is contained in:
Eric Bailey
2024-08-08 13:46:57 -05:00
129 changed files with 2877 additions and 1690 deletions
@@ -0,0 +1 @@
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 391 B

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

After

Width:  |  Height:  |  Size: 392 B

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

After

Width:  |  Height:  |  Size: 369 B

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

After

Width:  |  Height:  |  Size: 384 B

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

After

Width:  |  Height:  |  Size: 440 B

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

After

Width:  |  Height:  |  Size: 443 B

@@ -0,0 +1 @@
<svg 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

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

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