Merge branch 'main' into 0.74-fabric
@@ -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 |
@@ -116,4 +116,7 @@ jest.mock('expo-modules-core', () => ({
|
||||
}
|
||||
}
|
||||
}),
|
||||
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
|
||||
return () => null
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -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,18 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos", "android", "web"],
|
||||
"ios": {
|
||||
"modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoPlatformInfoModule"]
|
||||
"modules": [
|
||||
"ExpoBlueskySharedPrefsModule",
|
||||
"ExpoBlueskyReferrerModule",
|
||||
"ExpoBlueskyVisibilityViewModule",
|
||||
"ExpoPlatformInfoModule"
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
"modules": [
|
||||
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
|
||||
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
|
||||
"expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule",
|
||||
"expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as PlatformInfo from './src/PlatformInfo'
|
||||
import {AudioCategory} from './src/PlatformInfo/types'
|
||||
import * as Referrer from './src/Referrer'
|
||||
import * as SharedPrefs from './src/SharedPrefs'
|
||||
import VisibilityView from './src/VisibilityView'
|
||||
|
||||
export {PlatformInfo, Referrer, SharedPrefs}
|
||||
export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView}
|
||||
|
||||
@@ -7,5 +7,36 @@ public class ExpoPlatformInfoModule: Module {
|
||||
Function("getIsReducedMotionEnabled") {
|
||||
return UIAccessibility.isReduceMotionEnabled
|
||||
}
|
||||
|
||||
Function("setAudioCategory") { (audioCategoryString: String) in
|
||||
let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString)
|
||||
try? AVAudioSession.sharedInstance().setCategory(audioCategory)
|
||||
}
|
||||
|
||||
Function("setAudioActive") { (active: Bool) in
|
||||
var categoryOptions: AVAudioSession.CategoryOptions
|
||||
let currentCategory = AVAudioSession.sharedInstance().category
|
||||
|
||||
if active {
|
||||
categoryOptions = [.mixWithOthers]
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
} else {
|
||||
categoryOptions = [.duckOthers]
|
||||
try? AVAudioSession
|
||||
.sharedInstance()
|
||||
.setActive(
|
||||
false,
|
||||
options: [.notifyOthersOnDeactivation]
|
||||
)
|
||||
}
|
||||
|
||||
try? AVAudioSession
|
||||
.sharedInstance()
|
||||
.setCategory(
|
||||
currentCategory,
|
||||
mode: .default,
|
||||
options: categoryOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {requireNativeModule} from 'expo-modules-core'
|
||||
|
||||
import {AudioCategory} from './types'
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoPlatformInfo')
|
||||
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
return NativeModule.getIsReducedMotionEnabled()
|
||||
}
|
||||
|
||||
export function setAudioActive(active: boolean): void {
|
||||
if (Platform.OS !== 'ios') return
|
||||
NativeModule.setAudioActive(active)
|
||||
}
|
||||
|
||||
export function setAudioCategory(audioCategory: AudioCategory): void {
|
||||
if (Platform.OS !== 'ios') return
|
||||
NativeModule.setAudioCategory(audioCategory)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {AudioCategory} from './types'
|
||||
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
throw new NotImplementedError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the app's audio should mix with other apps' audio. Will also resume background music playback when `false`
|
||||
* if it was previously playing.
|
||||
* @param mixWithOthers
|
||||
* @see https://developer.apple.com/documentation/avfaudio/avaudiosession/setactiveoptions/1616603-notifyothersondeactivation
|
||||
*/
|
||||
export function setAudioActive(active: boolean): void {
|
||||
throw new NotImplementedError({active})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the audio category for the app.
|
||||
* @param audioCategory
|
||||
* @platform ios
|
||||
*/
|
||||
export function setAudioCategory(audioCategory: AudioCategory): void {
|
||||
throw new NotImplementedError({audioCategory})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {AudioCategory} from './types'
|
||||
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
||||
export function setAudioActive(active: boolean): void {
|
||||
throw new NotImplementedError({active})
|
||||
}
|
||||
|
||||
export function setAudioCategory(audioCategory: AudioCategory): void {
|
||||
throw new NotImplementedError({audioCategory})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Sets the audio session category on iOS. In general, we should only need to use this for the `playback` and `ambient`
|
||||
* categories. This enum however includes other categories that are available in the native API for clarity and
|
||||
* potential future use.
|
||||
* @see https://developer.apple.com/documentation/avfoundation/avaudiosession/category
|
||||
* @platform ios
|
||||
*/
|
||||
export enum AudioCategory {
|
||||
Ambient = 'AVAudioSessionCategoryAmbient',
|
||||
Playback = 'AVAudioSessionCategoryPlayback',
|
||||
_SoloAmbient = 'AVAudioSessionCategorySoloAmbient',
|
||||
_Record = 'AVAudioSessionCategoryRecord',
|
||||
_PlayAndRecord = 'AVAudioSessionCategoryPlayAndRecord',
|
||||
_MultiRoute = 'AVAudioSessionCategoryMultiRoute',
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {VisibilityViewProps} from './types'
|
||||
const NativeView: React.ComponentType<{
|
||||
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
|
||||
children: React.ReactNode
|
||||
enabled: Boolean
|
||||
style: StyleProp<ViewStyle>
|
||||
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoBlueskyVisibilityView')
|
||||
|
||||
export async function updateActiveViewAsync() {
|
||||
await NativeModule.updateActiveViewAsync()
|
||||
}
|
||||
|
||||
export default function VisibilityView({
|
||||
children,
|
||||
onChangeStatus: onChangeStatusOuter,
|
||||
enabled,
|
||||
}: VisibilityViewProps) {
|
||||
const onChangeStatus = React.useCallback(
|
||||
(e: {nativeEvent: {isActive: boolean}}) => {
|
||||
onChangeStatusOuter(e.nativeEvent.isActive)
|
||||
},
|
||||
[onChangeStatusOuter],
|
||||
)
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
onChangeStatus={onChangeStatus}
|
||||
enabled={enabled}
|
||||
style={{flex: 1}}>
|
||||
{children}
|
||||
</NativeView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {VisibilityViewProps} from './types'
|
||||
|
||||
export async function updateActiveViewAsync() {
|
||||
throw new NotImplementedError()
|
||||
}
|
||||
|
||||
export default function VisibilityView({children}: VisibilityViewProps) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
export interface VisibilityViewProps {
|
||||
children: React.ReactNode
|
||||
onChangeStatus: (isActive: boolean) => void
|
||||
enabled: boolean
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.12.26",
|
||||
"@atproto/api": "0.12.29",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.10",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
@@ -137,7 +137,7 @@
|
||||
"expo-system-ui": "~3.0.4",
|
||||
"expo-task-manager": "~11.8.2",
|
||||
"expo-updates": "~0.25.21",
|
||||
"expo-video": "^1.1.10",
|
||||
"expo-video": "^1.2.4",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
||||
@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
|
||||
setShowPreviousButton(!requireLinearPlayback)
|
||||
setShowNextButton(!requireLinearPlayback)
|
||||
setTimeBarInteractive(requireLinearPlayback)
|
||||
+ setShowSubtitleButton(true)
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
|
||||
- val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
||||
+ val fullscreenButton =
|
||||
+ findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
||||
fullscreenButton?.visibility = if (visible) {
|
||||
android.view.View.VISIBLE
|
||||
} else {
|
||||
@@ -0,0 +1,135 @@
|
||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
||||
index 9905e13..47342ff 100644
|
||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
||||
@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
|
||||
setShowPreviousButton(!requireLinearPlayback)
|
||||
setShowNextButton(!requireLinearPlayback)
|
||||
setTimeBarInteractive(requireLinearPlayback)
|
||||
+ setShowSubtitleButton(true)
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
|
||||
- val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
||||
+ val fullscreenButton =
|
||||
+ findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
||||
fullscreenButton?.visibility = if (visible) {
|
||||
android.view.View.VISIBLE
|
||||
} else {
|
||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
||||
index ec3da2a..5a1397a 100644
|
||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
||||
@@ -43,7 +43,9 @@ class VideoModule : Module() {
|
||||
View(VideoView::class) {
|
||||
Events(
|
||||
"onPictureInPictureStart",
|
||||
- "onPictureInPictureStop"
|
||||
+ "onPictureInPictureStop",
|
||||
+ "onEnterFullscreen",
|
||||
+ "onExitFullscreen"
|
||||
)
|
||||
|
||||
Prop("player") { view: VideoView, player: VideoPlayer ->
|
||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
||||
index a951d80..3932535 100644
|
||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
||||
@@ -36,6 +36,8 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
||||
val playerView: PlayerView = PlayerView(context.applicationContext)
|
||||
val onPictureInPictureStart by EventDispatcher<Unit>()
|
||||
val onPictureInPictureStop by EventDispatcher<Unit>()
|
||||
+ val onEnterFullscreen by EventDispatcher()
|
||||
+ val onExitFullscreen by EventDispatcher()
|
||||
|
||||
var willEnterPiP: Boolean = false
|
||||
var isInFullscreen: Boolean = false
|
||||
@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
||||
@Suppress("DEPRECATION")
|
||||
currentActivity.overridePendingTransition(0, 0)
|
||||
}
|
||||
+ onEnterFullscreen(mapOf())
|
||||
isInFullscreen = true
|
||||
}
|
||||
|
||||
@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
||||
val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen)
|
||||
fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter)
|
||||
videoPlayer?.changePlayerView(playerView)
|
||||
+ this.onExitFullscreen(mapOf())
|
||||
isInFullscreen = false
|
||||
}
|
||||
|
||||
diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts
|
||||
index cb9ca6d..60e9f4e 100644
|
||||
--- a/node_modules/expo-video/build/VideoView.types.d.ts
|
||||
+++ b/node_modules/expo-video/build/VideoView.types.d.ts
|
||||
@@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps {
|
||||
* @platform ios 16.0+
|
||||
*/
|
||||
allowsVideoFrameAnalysis?: boolean;
|
||||
+
|
||||
+ onEnterFullscreen?: () => void;
|
||||
+ onExitFullscreen?: () => void;
|
||||
}
|
||||
//# sourceMappingURL=VideoView.types.d.ts.map
|
||||
diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift
|
||||
index c537a12..e4a918f 100644
|
||||
--- a/node_modules/expo-video/ios/VideoModule.swift
|
||||
+++ b/node_modules/expo-video/ios/VideoModule.swift
|
||||
@@ -16,7 +16,9 @@ public final class VideoModule: Module {
|
||||
View(VideoView.self) {
|
||||
Events(
|
||||
"onPictureInPictureStart",
|
||||
- "onPictureInPictureStop"
|
||||
+ "onPictureInPictureStop",
|
||||
+ "onEnterFullscreen",
|
||||
+ "onExitFullscreen"
|
||||
)
|
||||
|
||||
Prop("player") { (view, player: VideoPlayer?) in
|
||||
diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift
|
||||
index f4579e4..10c5908 100644
|
||||
--- a/node_modules/expo-video/ios/VideoView.swift
|
||||
+++ b/node_modules/expo-video/ios/VideoView.swift
|
||||
@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
||||
|
||||
let onPictureInPictureStart = EventDispatcher()
|
||||
let onPictureInPictureStop = EventDispatcher()
|
||||
+ let onEnterFullscreen = EventDispatcher()
|
||||
+ let onExitFullscreen = EventDispatcher()
|
||||
|
||||
public override var bounds: CGRect {
|
||||
didSet {
|
||||
@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
||||
_ playerViewController: AVPlayerViewController,
|
||||
willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
|
||||
) {
|
||||
+ onEnterFullscreen()
|
||||
isFullscreen = true
|
||||
}
|
||||
|
||||
@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
||||
if wasPlaying {
|
||||
self.player?.pointer.play()
|
||||
}
|
||||
+ self.onExitFullscreen()
|
||||
self.isFullscreen = false
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
|
||||
index 29fe5db..e1fbf59 100644
|
||||
--- a/node_modules/expo-video/src/VideoView.types.ts
|
||||
+++ b/node_modules/expo-video/src/VideoView.types.ts
|
||||
@@ -100,4 +100,7 @@ export interface VideoViewProps extends ViewProps {
|
||||
* @platform ios 16.0+
|
||||
*/
|
||||
allowsVideoFrameAnalysis?: boolean;
|
||||
+
|
||||
+ onEnterFullscreen?: () => void;
|
||||
+ onExitFullscreen?: () => void;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
## uwu woad beawing, do not wemove
|
||||
|
||||
## `expo-video` Patch
|
||||
|
||||
This patch adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
|
||||
the tin.
|
||||
@@ -61,6 +61,7 @@ import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Splash} from '#/Splash'
|
||||
import {Provider as TourProvider} from '#/tours'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
@@ -157,6 +158,8 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||
PlatformInfo.setAudioActive(true)
|
||||
initPersistedState().then(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -186,19 +186,19 @@ export function createThemes({
|
||||
white: color.gray_0,
|
||||
black: color.trueBlack,
|
||||
|
||||
contrast_25: color.gray_1000,
|
||||
contrast_50: color.gray_975,
|
||||
contrast_100: color.gray_950,
|
||||
contrast_200: color.gray_900,
|
||||
contrast_300: color.gray_800,
|
||||
contrast_400: color.gray_700,
|
||||
contrast_500: color.gray_600,
|
||||
contrast_600: color.gray_500,
|
||||
contrast_700: color.gray_400,
|
||||
contrast_800: color.gray_300,
|
||||
contrast_900: color.gray_200,
|
||||
contrast_950: color.gray_100,
|
||||
contrast_975: color.gray_50,
|
||||
contrast_25: color.gray_975,
|
||||
contrast_50: color.gray_950,
|
||||
contrast_100: color.gray_900,
|
||||
contrast_200: color.gray_800,
|
||||
contrast_300: color.gray_700,
|
||||
contrast_400: color.gray_600,
|
||||
contrast_500: color.gray_500,
|
||||
contrast_600: color.gray_400,
|
||||
contrast_700: color.gray_300,
|
||||
contrast_800: color.gray_200,
|
||||
contrast_900: color.gray_100,
|
||||
contrast_950: color.gray_50,
|
||||
contrast_975: color.gray_25,
|
||||
|
||||
primary_25: color.primary_975,
|
||||
primary_50: color.primary_950,
|
||||
@@ -400,7 +400,7 @@ export function createThemes({
|
||||
color: darkPalette.contrast_400,
|
||||
},
|
||||
text_contrast_medium: {
|
||||
color: darkPalette.contrast_700,
|
||||
color: darkPalette.contrast_600,
|
||||
},
|
||||
text_contrast_high: {
|
||||
color: darkPalette.contrast_900,
|
||||
|
||||
@@ -202,28 +202,10 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
} else if (color === 'secondary') {
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.contrast_25,
|
||||
dim: t.palette.contrast_100,
|
||||
dark: t.palette.contrast_100,
|
||||
}),
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.contrast_50,
|
||||
dim: t.palette.contrast_200,
|
||||
dark: t.palette.contrast_200,
|
||||
}),
|
||||
})
|
||||
baseStyles.push(t.atoms.bg_contrast_25)
|
||||
hoverStyles.push(t.atoms.bg_contrast_50)
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.contrast_100,
|
||||
dim: t.palette.contrast_25,
|
||||
dark: t.palette.contrast_25,
|
||||
}),
|
||||
})
|
||||
baseStyles.push(t.atoms.bg_contrast_100)
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
|
||||
@@ -92,14 +92,16 @@ function getRank(seenPost: SeenPost): string {
|
||||
tier = 'a'
|
||||
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
||||
tier = 'b'
|
||||
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||
} else if (seenPost.feedContext === 'popcluster') {
|
||||
tier = 'c'
|
||||
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||
tier = 'd'
|
||||
} else if (seenPost.feedContext === 'nettop') {
|
||||
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||
tier = 'e'
|
||||
} else {
|
||||
} else if (seenPost.feedContext === 'nettop') {
|
||||
tier = 'f'
|
||||
} else {
|
||||
tier = 'g'
|
||||
}
|
||||
let score = Math.round(
|
||||
Math.log(
|
||||
@@ -131,16 +133,26 @@ function useExperimentalSuggestedUsersQuery() {
|
||||
const {currentAccount} = useSession()
|
||||
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
||||
const dids = React.useMemo(() => {
|
||||
const {likes, follows, seen} = userActionSnapshot
|
||||
const {likes, follows, followSuggestions, seen} = userActionSnapshot
|
||||
const likeDids = likes
|
||||
.map(l => new AtUri(l))
|
||||
.map(uri => uri.host)
|
||||
.filter(did => !follows.includes(did))
|
||||
let suggestedDids: string[] = []
|
||||
if (followSuggestions.length > 0) {
|
||||
suggestedDids = [
|
||||
// It's ok if these will pick the same item (weighed by its frequency)
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
]
|
||||
}
|
||||
const seenDids = seen
|
||||
.sort(sortSeenPosts)
|
||||
.map(l => new AtUri(l.uri))
|
||||
.map(uri => uri.host)
|
||||
return [...new Set([...likeDids, ...seenDids])].filter(
|
||||
return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
|
||||
did => did !== currentAccount?.did,
|
||||
)
|
||||
}, [userActionSnapshot, currentAccount])
|
||||
|
||||
@@ -249,7 +249,9 @@ function DialogError({details}: {details?: string}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner style={a.gap_md} label={_(msg`An error occured`)}>
|
||||
<Dialog.ScrollableInner
|
||||
style={a.gap_md}
|
||||
label={_(msg`An error has occurred`)}>
|
||||
<Dialog.Close />
|
||||
<ErrorScreen
|
||||
title={_(msg`Oh no!`)}
|
||||
|
||||
@@ -193,6 +193,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
lineHeight: a.text_md.fontSize * 1.1875,
|
||||
textAlignVertical: rest.multiline ? 'top' : undefined,
|
||||
minHeight: rest.multiline ? 80 : undefined,
|
||||
minWidth: 0,
|
||||
},
|
||||
// fix for autofill styles covering border
|
||||
web({
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const ArrowsDiagonalOut_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const ArrowsDiagonalIn_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
|
||||
export const ArrowsDiagonalOut_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const ArrowsDiagonalIn_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const CC_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z',
|
||||
})
|
||||
|
||||
export const CC_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z',
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Pause_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z',
|
||||
})
|
||||
|
||||
export const Pause_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z',
|
||||
})
|
||||
|
||||
export const Pause_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z',
|
||||
})
|
||||
|
||||
export const Pause_Filled_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z',
|
||||
})
|
||||
@@ -1,5 +1,13 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Play_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z',
|
||||
})
|
||||
|
||||
export const Play_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z',
|
||||
})
|
||||
|
||||
export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z',
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -40,7 +40,7 @@ export function ContentHider({
|
||||
|
||||
if (!blur || (ignoreMute && isJustAMute(modui))) {
|
||||
return (
|
||||
<View testID={testID} style={[styles.outer, style]}>
|
||||
<View testID={testID} style={style}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
@@ -163,21 +163,3 @@ export function ContentHider({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {},
|
||||
cover: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
paddingVertical: 14,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 18,
|
||||
},
|
||||
showBtn: {
|
||||
marginLeft: 'auto',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
@@ -6,50 +7,125 @@ import {
|
||||
} from '@atproto/api'
|
||||
|
||||
import {isPostInLanguage} from '../../locale/helpers'
|
||||
import {FALLBACK_MARKER_POST} from './feed/home'
|
||||
import {ReasonFeedSource} from './feed/types'
|
||||
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
|
||||
export type FeedTunerFn = (
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
dryRun: boolean,
|
||||
) => FeedViewPostsSlice[]
|
||||
|
||||
type FeedSliceItem = {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
reply?: AppBskyFeedDefs.ReplyRef
|
||||
record: AppBskyFeedPost.Record
|
||||
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
isParentBlocked: boolean
|
||||
}
|
||||
|
||||
function toSliceItem(feedViewPost: FeedViewPost): FeedSliceItem {
|
||||
return {
|
||||
post: feedViewPost.post,
|
||||
reply: feedViewPost.reply,
|
||||
}
|
||||
type AuthorContext = {
|
||||
author: AppBskyActorDefs.ProfileViewBasic
|
||||
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
}
|
||||
|
||||
export class FeedViewPostsSlice {
|
||||
_reactKey: string
|
||||
_feedPost: FeedViewPost
|
||||
items: FeedSliceItem[]
|
||||
isIncompleteThread: boolean
|
||||
isFallbackMarker: boolean
|
||||
isOrphan: boolean
|
||||
rootUri: string
|
||||
|
||||
constructor(feedPost: FeedViewPost) {
|
||||
const {post, reply, reason} = feedPost
|
||||
this.items = []
|
||||
this.isIncompleteThread = false
|
||||
this.isFallbackMarker = false
|
||||
this.isOrphan = false
|
||||
if (AppBskyFeedDefs.isPostView(reply?.root)) {
|
||||
this.rootUri = reply.root.uri
|
||||
} else {
|
||||
this.rootUri = post.uri
|
||||
}
|
||||
this._feedPost = feedPost
|
||||
this._reactKey = `slice-${feedPost.post.uri}-${
|
||||
feedPost.reason?.indexedAt || feedPost.post.indexedAt
|
||||
this._reactKey = `slice-${post.uri}-${
|
||||
feedPost.reason?.indexedAt || post.indexedAt
|
||||
}`
|
||||
this.items = [toSliceItem(feedPost)]
|
||||
}
|
||||
|
||||
get uri() {
|
||||
return this._feedPost.post.uri
|
||||
}
|
||||
|
||||
get isThread() {
|
||||
return (
|
||||
this.items.length > 1 &&
|
||||
this.items.every(
|
||||
item => item.post.author.did === this.items[0].post.author.did,
|
||||
)
|
||||
if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) {
|
||||
this.isFallbackMarker = true
|
||||
return
|
||||
}
|
||||
if (
|
||||
!AppBskyFeedPost.isRecord(post.record) ||
|
||||
!AppBskyFeedPost.validateRecord(post.record).success
|
||||
) {
|
||||
return
|
||||
}
|
||||
const parent = reply?.parent
|
||||
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
|
||||
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||
parentAuthor = parent.author
|
||||
}
|
||||
this.items.push({
|
||||
post,
|
||||
record: post.record,
|
||||
parentAuthor,
|
||||
isParentBlocked,
|
||||
})
|
||||
if (!reply || reason) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!AppBskyFeedDefs.isPostView(parent) ||
|
||||
!AppBskyFeedPost.isRecord(parent.record) ||
|
||||
!AppBskyFeedPost.validateRecord(parent.record).success
|
||||
) {
|
||||
this.isOrphan = true
|
||||
return
|
||||
}
|
||||
const grandparentAuthor = reply.grandparentAuthor
|
||||
const isGrandparentBlocked = Boolean(
|
||||
grandparentAuthor?.viewer?.blockedBy ||
|
||||
grandparentAuthor?.viewer?.blocking ||
|
||||
grandparentAuthor?.viewer?.blockingByList,
|
||||
)
|
||||
this.items.unshift({
|
||||
post: parent,
|
||||
record: parent.record,
|
||||
parentAuthor: grandparentAuthor,
|
||||
isParentBlocked: isGrandparentBlocked,
|
||||
})
|
||||
if (isGrandparentBlocked) {
|
||||
this.isOrphan = true
|
||||
// Keep going, it might still have a root.
|
||||
}
|
||||
const root = reply.root
|
||||
if (
|
||||
!AppBskyFeedDefs.isPostView(root) ||
|
||||
!AppBskyFeedPost.isRecord(root.record) ||
|
||||
!AppBskyFeedPost.validateRecord(root.record).success
|
||||
) {
|
||||
this.isOrphan = true
|
||||
return
|
||||
}
|
||||
if (root.uri === parent.uri) {
|
||||
return
|
||||
}
|
||||
this.items.unshift({
|
||||
post: root,
|
||||
record: root.record,
|
||||
isParentBlocked: false,
|
||||
parentAuthor: undefined,
|
||||
})
|
||||
if (parent.record.reply?.parent.uri !== root.uri) {
|
||||
this.isIncompleteThread = true
|
||||
}
|
||||
}
|
||||
|
||||
get isQuotePost() {
|
||||
@@ -90,204 +166,171 @@ export class FeedViewPostsSlice {
|
||||
return !!this.items.find(item => item.post.uri === uri)
|
||||
}
|
||||
|
||||
isNextInThread(uri: string) {
|
||||
return this.items[this.items.length - 1].post.uri === uri
|
||||
}
|
||||
|
||||
insert(item: FeedViewPost) {
|
||||
const selfReplyUri = getSelfReplyUri(item)
|
||||
const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri)
|
||||
if (i !== -1) {
|
||||
this.items.splice(i + 1, 0, item)
|
||||
} else {
|
||||
this.items.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
flattenReplyParent() {
|
||||
if (this.items[0].reply) {
|
||||
const reply = this.items[0].reply
|
||||
if (AppBskyFeedDefs.isPostView(reply.parent)) {
|
||||
this.items.splice(0, 0, {post: reply.parent})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isFollowingAllAuthors(userDid: string) {
|
||||
getAuthors(): AuthorContext {
|
||||
const feedPost = this._feedPost
|
||||
const authors = [feedPost.post.author]
|
||||
let author: AppBskyActorDefs.ProfileViewBasic = feedPost.post.author
|
||||
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
let grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
let rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
if (feedPost.reply) {
|
||||
if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) {
|
||||
authors.push(feedPost.reply.parent.author)
|
||||
parentAuthor = feedPost.reply.parent.author
|
||||
}
|
||||
if (feedPost.reply.grandparentAuthor) {
|
||||
authors.push(feedPost.reply.grandparentAuthor)
|
||||
grandparentAuthor = feedPost.reply.grandparentAuthor
|
||||
}
|
||||
if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) {
|
||||
authors.push(feedPost.reply.root.author)
|
||||
rootAuthor = feedPost.reply.root.author
|
||||
}
|
||||
}
|
||||
return authors.every(a => a.did === userDid || a.viewer?.following)
|
||||
return {
|
||||
author,
|
||||
parentAuthor,
|
||||
grandparentAuthor,
|
||||
rootAuthor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FeedTuner {
|
||||
seenKeys: Set<string> = new Set()
|
||||
seenUris: Set<string> = new Set()
|
||||
seenRootUris: Set<string> = new Set()
|
||||
|
||||
constructor(public tunerFns: FeedTunerFn[]) {}
|
||||
|
||||
reset() {
|
||||
this.seenKeys.clear()
|
||||
this.seenUris.clear()
|
||||
}
|
||||
|
||||
tune(
|
||||
feed: FeedViewPost[],
|
||||
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
|
||||
{dryRun}: {dryRun: boolean} = {
|
||||
dryRun: false,
|
||||
maintainOrder: false,
|
||||
},
|
||||
): FeedViewPostsSlice[] {
|
||||
let slices: FeedViewPostsSlice[] = []
|
||||
let slices: FeedViewPostsSlice[] = feed
|
||||
.map(item => new FeedViewPostsSlice(item))
|
||||
.filter(s => s.items.length > 0 || s.isFallbackMarker)
|
||||
|
||||
// remove posts that are replies, but which don't have the parent
|
||||
// hydrated. this means the parent was either deleted or blocked
|
||||
feed = feed.filter(item => {
|
||||
if (
|
||||
AppBskyFeedPost.isRecord(item.post.record) &&
|
||||
item.post.record.reply &&
|
||||
!item.reply
|
||||
) {
|
||||
// run the custom tuners
|
||||
for (const tunerFn of this.tunerFns) {
|
||||
slices = tunerFn(this, slices.slice(), dryRun)
|
||||
}
|
||||
|
||||
slices = slices.filter(slice => {
|
||||
if (this.seenKeys.has(slice._reactKey)) {
|
||||
return false
|
||||
}
|
||||
// Some feeds, like Following, dedupe by thread, so you only see the most recent reply.
|
||||
// However, we don't want per-thread dedupe for author feeds (where we need to show every post)
|
||||
// or for feedgens (where we want to let the feed serve multiple replies if it chooses to).
|
||||
// To avoid showing the same context (root and/or parent) more than once, we do last resort
|
||||
// per-post deduplication. It hides already seen posts as long as this doesn't break the thread.
|
||||
for (let i = 0; i < slice.items.length; i++) {
|
||||
const item = slice.items[i]
|
||||
if (this.seenUris.has(item.post.uri)) {
|
||||
if (i === 0) {
|
||||
// Omit contiguous seen leading items.
|
||||
// For example, [A -> B -> C], [A -> D -> E], [A -> D -> F]
|
||||
// would turn into [A -> B -> C], [D -> E], [F].
|
||||
slice.items.splice(0, 1)
|
||||
i--
|
||||
}
|
||||
if (i === slice.items.length - 1) {
|
||||
// If the last item in the slice was already seen, omit the whole slice.
|
||||
// This means we'd miss its parents, but the user can "show more" to see them.
|
||||
// For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C]
|
||||
// would get collapsed into [A ... E -> F], with B/C/D considered seen.
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if (!dryRun) {
|
||||
this.seenUris.add(item.post.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dryRun) {
|
||||
this.seenKeys.add(slice._reactKey)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (maintainOrder) {
|
||||
slices = feed.map(item => new FeedViewPostsSlice(item))
|
||||
} else {
|
||||
// arrange the posts into thread slices
|
||||
for (let i = feed.length - 1; i >= 0; i--) {
|
||||
const item = feed[i]
|
||||
|
||||
const selfReplyUri = getSelfReplyUri(item)
|
||||
if (selfReplyUri) {
|
||||
const index = slices.findIndex(slice =>
|
||||
slice.isNextInThread(selfReplyUri),
|
||||
)
|
||||
|
||||
if (index !== -1) {
|
||||
const parent = slices[index]
|
||||
|
||||
parent.insert(item)
|
||||
|
||||
// If our slice isn't currently on the top, reinsert it to the top.
|
||||
if (index !== 0) {
|
||||
slices.splice(index, 1)
|
||||
slices.unshift(parent)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
slices.unshift(new FeedViewPostsSlice(item))
|
||||
}
|
||||
}
|
||||
|
||||
// run the custom tuners
|
||||
for (const tunerFn of this.tunerFns) {
|
||||
slices = tunerFn(this, slices.slice())
|
||||
}
|
||||
|
||||
// remove any items already "seen"
|
||||
const soonToBeSeenUris: Set<string> = new Set()
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (!slices[i].isThread && this.seenUris.has(slices[i].uri)) {
|
||||
slices.splice(i, 1)
|
||||
} else {
|
||||
for (const item of slices[i].items) {
|
||||
soonToBeSeenUris.add(item.post.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// turn non-threads with reply parents into threads
|
||||
for (const slice of slices) {
|
||||
if (!slice.isThread && !slice.reason && slice.items[0].reply) {
|
||||
const reply = slice.items[0].reply
|
||||
if (
|
||||
AppBskyFeedDefs.isPostView(reply.parent) &&
|
||||
!this.seenUris.has(reply.parent.uri) &&
|
||||
!soonToBeSeenUris.has(reply.parent.uri)
|
||||
) {
|
||||
const uri = reply.parent.uri
|
||||
slice.flattenReplyParent()
|
||||
soonToBeSeenUris.add(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
slices = slices.filter(slice => {
|
||||
if (this.seenKeys.has(slice._reactKey)) {
|
||||
return false
|
||||
}
|
||||
for (const item of slice.items) {
|
||||
this.seenUris.add(item.post.uri)
|
||||
}
|
||||
this.seenKeys.add(slice._reactKey)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (slices[i].isReply) {
|
||||
slices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (slices[i].isRepost) {
|
||||
slices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (slices[i].isQuotePost) {
|
||||
slices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static dedupReposts(
|
||||
static removeReplies(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
): FeedViewPostsSlice[] {
|
||||
// remove duplicates caused by reposts
|
||||
_dryRun: boolean,
|
||||
) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const item1 = slices[i]
|
||||
for (let j = i + 1; j < slices.length; j++) {
|
||||
const item2 = slices[j]
|
||||
if (item2.isThread) {
|
||||
// dont dedup items that are rendering in a thread as this can cause rendering errors
|
||||
continue
|
||||
}
|
||||
if (item1.containsUri(item2.items[0].post.uri)) {
|
||||
slices.splice(j, 1)
|
||||
j--
|
||||
const slice = slices[i]
|
||||
if (
|
||||
slice.isReply &&
|
||||
!slice.isRepost &&
|
||||
// This is not perfect but it's close as we can get to
|
||||
// detecting threads without having to peek ahead.
|
||||
!areSameAuthor(slice.getAuthors())
|
||||
) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeReposts(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
if (slices[i].isRepost) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeQuotePosts(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
if (slices[i].isQuotePost) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeOrphans(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
if (slices[i].isOrphan) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static dedupThreads(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
dryRun: boolean,
|
||||
): FeedViewPostsSlice[] {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const rootUri = slices[i].rootUri
|
||||
if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
} else {
|
||||
if (!dryRun) {
|
||||
tuner.seenRootUris.add(rootUri)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,15 +341,17 @@ export class FeedTuner {
|
||||
return (
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
): FeedViewPostsSlice[] => {
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const slice = slices[i]
|
||||
if (
|
||||
slice.isReply &&
|
||||
!slice.isRepost &&
|
||||
!slice.isFollowingAllAuthors(userDid)
|
||||
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
|
||||
) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
@@ -324,6 +369,7 @@ export class FeedTuner {
|
||||
return (
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
): FeedViewPostsSlice[] => {
|
||||
const candidateSlices = slices.slice()
|
||||
|
||||
@@ -332,7 +378,7 @@ export class FeedTuner {
|
||||
return slices
|
||||
}
|
||||
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
let hasPreferredLang = false
|
||||
for (const item of slices[i].items) {
|
||||
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
||||
@@ -358,16 +404,66 @@ export class FeedTuner {
|
||||
}
|
||||
}
|
||||
|
||||
function getSelfReplyUri(item: FeedViewPost): string | undefined {
|
||||
if (item.reply) {
|
||||
if (
|
||||
AppBskyFeedDefs.isPostView(item.reply.parent) &&
|
||||
!AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies
|
||||
) {
|
||||
return item.reply.parent.author.did === item.post.author.did
|
||||
? item.reply.parent.uri
|
||||
: undefined
|
||||
}
|
||||
function areSameAuthor(authors: AuthorContext): boolean {
|
||||
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||
const authorDid = author.did
|
||||
if (parentAuthor && parentAuthor.did !== authorDid) {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
if (grandparentAuthor && grandparentAuthor.did !== authorDid) {
|
||||
return false
|
||||
}
|
||||
if (rootAuthor && rootAuthor.did !== authorDid) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldDisplayReplyInFollowing(
|
||||
authors: AuthorContext,
|
||||
userDid: string,
|
||||
): boolean {
|
||||
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||
if (!isSelfOrFollowing(author, userDid)) {
|
||||
// Only show replies from self or people you follow.
|
||||
return false
|
||||
}
|
||||
if (
|
||||
(!parentAuthor || parentAuthor.did === author.did) &&
|
||||
(!rootAuthor || rootAuthor.did === author.did) &&
|
||||
(!grandparentAuthor || grandparentAuthor.did === author.did)
|
||||
) {
|
||||
// Always show self-threads.
|
||||
return true
|
||||
}
|
||||
// From this point on we need at least one more reason to show it.
|
||||
if (
|
||||
parentAuthor &&
|
||||
parentAuthor.did !== author.did &&
|
||||
isSelfOrFollowing(parentAuthor, userDid)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
grandparentAuthor &&
|
||||
grandparentAuthor.did !== author.did &&
|
||||
isSelfOrFollowing(grandparentAuthor, userDid)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
rootAuthor &&
|
||||
rootAuthor.did !== author.did &&
|
||||
isSelfOrFollowing(rootAuthor, userDid)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isSelfOrFollowing(
|
||||
profile: AppBskyActorDefs.ProfileViewBasic,
|
||||
userDid: string,
|
||||
) {
|
||||
return Boolean(profile.did === userDid || profile.viewer?.following)
|
||||
}
|
||||
|
||||
@@ -193,12 +193,6 @@ class MergeFeedSource {
|
||||
return this.hasMore && this.queue.length === 0
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
this.queue = []
|
||||
this.hasMore = true
|
||||
}
|
||||
|
||||
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
|
||||
return this.queue.splice(0, n)
|
||||
}
|
||||
@@ -232,11 +226,6 @@ class MergeFeedSource {
|
||||
class MergeFeedSource_Following extends MergeFeedSource {
|
||||
tuner = new FeedTuner(this.feedTuners)
|
||||
|
||||
reset() {
|
||||
super.reset()
|
||||
this.tuner.reset()
|
||||
}
|
||||
|
||||
async fetchNext(n: number) {
|
||||
return this._fetchNextInner(n)
|
||||
}
|
||||
@@ -249,7 +238,6 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
||||
// run the tuner pre-emptively to ensure better mixing
|
||||
const slices = this.tuner.tune(res.data.feed, {
|
||||
dryRun: false,
|
||||
maintainOrder: true,
|
||||
})
|
||||
res.data.feed = slices.map(slice => slice._feedPost)
|
||||
return res
|
||||
|
||||
@@ -126,7 +126,7 @@ export function useModerationCauseDescription(
|
||||
}
|
||||
}
|
||||
if (def.identifier === 'porn' || def.identifier === 'sexual') {
|
||||
strings.name = 'Adult Content'
|
||||
strings.name = _(msg`Adult Content`)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {BskyAgent} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {SessionAccount, useAgent, useSession} from '#/state/session'
|
||||
import {logEvent, useGate} from 'lib/statsig/statsig'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {devicePlatform, isAndroid, isNative} from 'platform/detection'
|
||||
import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'
|
||||
|
||||
@@ -86,7 +86,6 @@ export function useNotificationsRegistration() {
|
||||
}
|
||||
|
||||
export function useRequestNotificationsPermission() {
|
||||
const gate = useGate()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
@@ -102,16 +101,7 @@ export function useRequestNotificationsPermission() {
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
context === 'StartOnboarding' &&
|
||||
gate('request_notifications_permission_after_onboarding_v2')
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
context === 'AfterOnboarding' &&
|
||||
!gate('request_notifications_permission_after_onboarding_v2')
|
||||
) {
|
||||
if (context === 'AfterOnboarding') {
|
||||
return
|
||||
}
|
||||
if (context === 'Home' && !currentAccount) {
|
||||
|
||||
@@ -159,6 +159,7 @@ export type LogEvents = {
|
||||
| 'AvatarButton'
|
||||
| 'StarterPackProfilesList'
|
||||
| 'FeedInterstitial'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
}
|
||||
'profile:unfollow': {
|
||||
logContext:
|
||||
@@ -173,6 +174,7 @@ export type LogEvents = {
|
||||
| 'AvatarButton'
|
||||
| 'StarterPackProfilesList'
|
||||
| 'FeedInterstitial'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
}
|
||||
'chat:create': {
|
||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||
@@ -211,6 +213,14 @@ export type LogEvents = {
|
||||
'feed:interstitial:profileCard:press': {}
|
||||
'feed:interstitial:feedCard:press': {}
|
||||
|
||||
'profile:header:suggestedFollowsCard:press': {}
|
||||
|
||||
'debug:followingPrefs': {
|
||||
followingShowRepliesFromPref: 'all' | 'following' | 'off'
|
||||
followingRepliesMinLikePref: number
|
||||
}
|
||||
'debug:followingDisplayed': {}
|
||||
|
||||
'test:all:always': {}
|
||||
'test:all:sometimes': {}
|
||||
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'debug_show_feedcontext'
|
||||
| 'explore_page_profile_card_social_proof'
|
||||
| 'native_pwi_disabled'
|
||||
| 'new_user_guided_tour'
|
||||
| 'new_user_progress_guide'
|
||||
| 'onboarding_minimum_interests'
|
||||
| 'request_notifications_permission_after_onboarding_v2'
|
||||
| 'session_withproxy_fix'
|
||||
| 'show_avi_follow_button'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'suggested_feeds_interstitial'
|
||||
| 'suggested_follows_interstitial'
|
||||
| 'ungroup_follow_backs'
|
||||
| 'video_debug'
|
||||
| 'videos'
|
||||
| 'small_avi_thumb'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native'
|
||||
import {Theme, TypographyVariant} from './ThemeContext'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {Theme, TypographyVariant} from './ThemeContext'
|
||||
|
||||
// 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
|
||||
export const colors = {
|
||||
|
||||
@@ -295,9 +295,9 @@ export const darkTheme: Theme = {
|
||||
...defaultTheme.palette,
|
||||
default: {
|
||||
background: darkPalette.black,
|
||||
backgroundLight: darkPalette.contrast_50,
|
||||
backgroundLight: darkPalette.contrast_25,
|
||||
text: darkPalette.white,
|
||||
textLight: darkPalette.contrast_700,
|
||||
textLight: darkPalette.contrast_600,
|
||||
textInverted: darkPalette.black,
|
||||
link: darkPalette.primary_500,
|
||||
border: darkPalette.contrast_100,
|
||||
@@ -344,7 +344,7 @@ export const dimTheme: Theme = {
|
||||
default: {
|
||||
...darkTheme.palette.default,
|
||||
background: dimPalette.black,
|
||||
backgroundLight: dimPalette.contrast_50,
|
||||
backgroundLight: dimPalette.contrast_25,
|
||||
text: dimPalette.white,
|
||||
textLight: dimPalette.contrast_700,
|
||||
textInverted: dimPalette.black,
|
||||
|
||||
@@ -14,6 +14,7 @@ export const isMobileWeb =
|
||||
isWeb &&
|
||||
// @ts-ignore we know window exists -prf
|
||||
global.window.matchMedia(isMobileWebMediaQuery)?.matches
|
||||
export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent)
|
||||
|
||||
export const deviceLocales = dedupArray(
|
||||
getLocales?.()
|
||||
|
||||
@@ -387,9 +387,6 @@ export function MessagesList({
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableFullWindowScroll={true}
|
||||
// Prevents wrong position in Firefox when sending a message
|
||||
// as well as scroll getting stuck on Chome when scrolling upwards.
|
||||
disableContainStyle={true}
|
||||
disableVirtualization={true}
|
||||
style={animatedListStyle}
|
||||
// The extra two items account for the header and the footer components
|
||||
|
||||
@@ -157,7 +157,7 @@ let ProfileHeaderStandard = ({
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={isPlaceholderProfile}>
|
||||
<View
|
||||
style={[a.px_lg, a.pt_md, a.pb_sm]}
|
||||
style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -79,6 +79,7 @@ export const ProfileFeedSection = React.forwardRef<
|
||||
headerOffset={headerHeight}
|
||||
renderEndOfFeed={ProfileEndOfFeed}
|
||||
ignoreFilterFor={ignoreFilterFor}
|
||||
outsideHeaderOffset={headerHeight}
|
||||
/>
|
||||
{(isScrolledDown || hasNew) && (
|
||||
<LoadLatestBtn
|
||||
|
||||
@@ -123,7 +123,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
||||
toString({
|
||||
item: postItem.uri,
|
||||
event: 'app.bsky.feed.defs#interactionSeen',
|
||||
feedContext: postItem.feedContext,
|
||||
feedContext: slice.feedContext,
|
||||
}),
|
||||
)
|
||||
sendToFeed()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = persisted.Schema['invites']
|
||||
@@ -35,8 +36,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('invites'))
|
||||
return persisted.onUpdate('invites', nextInvites => {
|
||||
setState(nextInvites)
|
||||
})
|
||||
}, [setState])
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import type {LegacySchema} from '#/state/persisted/legacy'
|
||||
|
||||
export const ALICE_DID = 'did:plc:ALICE_DID'
|
||||
export const BOB_DID = 'did:plc:BOB_DID'
|
||||
|
||||
export const LEGACY_DATA_DUMP: LegacySchema = {
|
||||
session: {
|
||||
data: {
|
||||
service: 'https://bsky.social/',
|
||||
did: ALICE_DID,
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
service: 'https://bsky.social',
|
||||
did: ALICE_DID,
|
||||
refreshJwt: 'refreshJwt',
|
||||
accessJwt: 'accessJwt',
|
||||
handle: 'alice.test',
|
||||
email: 'alice@bsky.test',
|
||||
displayName: 'Alice',
|
||||
aviUrl: 'avi',
|
||||
emailConfirmed: true,
|
||||
},
|
||||
{
|
||||
service: 'https://bsky.social',
|
||||
did: BOB_DID,
|
||||
refreshJwt: 'refreshJwt',
|
||||
accessJwt: 'accessJwt',
|
||||
handle: 'bob.test',
|
||||
email: 'bob@bsky.test',
|
||||
displayName: 'Bob',
|
||||
aviUrl: 'avi',
|
||||
emailConfirmed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
me: {
|
||||
did: ALICE_DID,
|
||||
handle: 'alice.test',
|
||||
displayName: 'Alice',
|
||||
description: '',
|
||||
avatar: 'avi',
|
||||
},
|
||||
onboarding: {step: 'Home'},
|
||||
shell: {colorMode: 'system'},
|
||||
preferences: {
|
||||
primaryLanguage: 'en',
|
||||
contentLanguages: ['en'],
|
||||
postLanguage: 'en',
|
||||
postLanguageHistory: ['en', 'en', 'ja', 'pt', 'de', 'en'],
|
||||
contentLabels: {
|
||||
nsfw: 'warn',
|
||||
nudity: 'warn',
|
||||
suggestive: 'warn',
|
||||
gore: 'warn',
|
||||
hate: 'hide',
|
||||
spam: 'hide',
|
||||
impersonation: 'warn',
|
||||
},
|
||||
savedFeeds: ['feed_a', 'feed_b', 'feed_c'],
|
||||
pinnedFeeds: ['feed_a', 'feed_b'],
|
||||
requireAltTextEnabled: false,
|
||||
},
|
||||
invitedUsers: {seenDids: [], copiedInvites: []},
|
||||
mutedThreads: {uris: []},
|
||||
reminders: {},
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import {jest, expect, test, afterEach} from '@jest/globals'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {defaults} from '#/state/persisted/schema'
|
||||
import {migrate} from '#/state/persisted/legacy'
|
||||
import * as store from '#/state/persisted/store'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
const write = jest.mocked(store.write)
|
||||
const read = jest.mocked(store.read)
|
||||
|
||||
jest.mock('#/logger')
|
||||
jest.mock('#/state/persisted/legacy', () => ({
|
||||
migrate: jest.fn(),
|
||||
}))
|
||||
jest.mock('#/state/persisted/store', () => ({
|
||||
write: jest.fn(),
|
||||
read: jest.fn(),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
jest.useFakeTimers()
|
||||
jest.clearAllMocks()
|
||||
AsyncStorage.clear()
|
||||
})
|
||||
|
||||
test('init: fresh install, no migration', async () => {
|
||||
await persisted.init()
|
||||
|
||||
expect(migrate).toHaveBeenCalledTimes(1)
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
expect(write).toHaveBeenCalledWith(defaults)
|
||||
|
||||
// default value
|
||||
expect(persisted.get('colorMode')).toBe('system')
|
||||
})
|
||||
|
||||
test('init: fresh install, migration ran', async () => {
|
||||
read.mockResolvedValueOnce(defaults)
|
||||
|
||||
await persisted.init()
|
||||
|
||||
expect(migrate).toHaveBeenCalledTimes(1)
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
|
||||
// default value
|
||||
expect(persisted.get('colorMode')).toBe('system')
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import {jest, expect, test, afterEach} from '@jest/globals'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {defaults, schema} from '#/state/persisted/schema'
|
||||
import {transform, migrate} from '#/state/persisted/legacy'
|
||||
import * as store from '#/state/persisted/store'
|
||||
import {logger} from '#/logger'
|
||||
import * as fixtures from '#/state/persisted/__tests__/fixtures'
|
||||
|
||||
const write = jest.mocked(store.write)
|
||||
const read = jest.mocked(store.read)
|
||||
|
||||
jest.mock('#/logger')
|
||||
jest.mock('#/state/persisted/store', () => ({
|
||||
write: jest.fn(),
|
||||
read: jest.fn(),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
AsyncStorage.clear()
|
||||
})
|
||||
|
||||
test('migrate: fresh install', async () => {
|
||||
await migrate()
|
||||
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'persisted state: no migration needed',
|
||||
)
|
||||
})
|
||||
|
||||
test('migrate: fresh install, existing new storage', async () => {
|
||||
read.mockResolvedValueOnce(defaults)
|
||||
|
||||
await migrate()
|
||||
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'persisted state: no migration needed',
|
||||
)
|
||||
})
|
||||
|
||||
test('migrate: fresh install, AsyncStorage error', async () => {
|
||||
const prevGetItem = AsyncStorage.getItem
|
||||
|
||||
const error = new Error('test error')
|
||||
|
||||
AsyncStorage.getItem = jest.fn(() => {
|
||||
throw error
|
||||
})
|
||||
|
||||
await migrate()
|
||||
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||
expect(logger.error).toHaveBeenCalledWith(error, {
|
||||
message: 'persisted state: error migrating legacy storage',
|
||||
})
|
||||
|
||||
AsyncStorage.getItem = prevGetItem
|
||||
})
|
||||
|
||||
test('migrate: has legacy data', async () => {
|
||||
await AsyncStorage.setItem('root', JSON.stringify(fixtures.LEGACY_DATA_DUMP))
|
||||
|
||||
await migrate()
|
||||
|
||||
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'persisted state: migrated legacy storage',
|
||||
)
|
||||
})
|
||||
|
||||
test('migrate: has legacy data, fails validation', async () => {
|
||||
const legacy = fixtures.LEGACY_DATA_DUMP
|
||||
// @ts-ignore
|
||||
legacy.shell.colorMode = 'invalid'
|
||||
await AsyncStorage.setItem('root', JSON.stringify(legacy))
|
||||
|
||||
await migrate()
|
||||
|
||||
const transformed = transform(legacy)
|
||||
const validate = schema.safeParse(transformed)
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'persisted state: legacy data failed validation',
|
||||
// @ts-ignore
|
||||
{message: validate.error},
|
||||
)
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
import {expect, test} from '@jest/globals'
|
||||
|
||||
import {transform} from '#/state/persisted/legacy'
|
||||
import {defaults, schema} from '#/state/persisted/schema'
|
||||
import * as fixtures from '#/state/persisted/__tests__/fixtures'
|
||||
|
||||
test('defaults', () => {
|
||||
expect(() => schema.parse(defaults)).not.toThrow()
|
||||
})
|
||||
|
||||
test('transform', () => {
|
||||
const data = transform({})
|
||||
expect(() => schema.parse(data)).not.toThrow()
|
||||
})
|
||||
|
||||
test('transform: legacy fixture', () => {
|
||||
const data = transform(fixtures.LEGACY_DATA_DUMP)
|
||||
expect(() => schema.parse(data)).not.toThrow()
|
||||
expect(data.session.currentAccount?.did).toEqual(fixtures.ALICE_DID)
|
||||
expect(data.session.accounts.length).toEqual(2)
|
||||
})
|
||||
@@ -1,97 +1,86 @@
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import BroadcastChannel from '#/lib/broadcast'
|
||||
import {logger} from '#/logger'
|
||||
import {migrate} from '#/state/persisted/legacy'
|
||||
import {defaults, Schema} from '#/state/persisted/schema'
|
||||
import * as store from '#/state/persisted/store'
|
||||
import {
|
||||
defaults,
|
||||
Schema,
|
||||
tryParse,
|
||||
tryStringify,
|
||||
} from '#/state/persisted/schema'
|
||||
import {PersistedApi} from './types'
|
||||
|
||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||
export {defaults} from '#/state/persisted/schema'
|
||||
|
||||
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
||||
const UPDATE_EVENT = 'BSKY_UPDATE'
|
||||
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||
|
||||
let _state: Schema = defaults
|
||||
const _emitter = new EventEmitter()
|
||||
|
||||
/**
|
||||
* Initializes and returns persisted data state, so that it can be passed to
|
||||
* the Provider.
|
||||
*/
|
||||
export async function init() {
|
||||
logger.debug('persisted state: initializing')
|
||||
|
||||
broadcast.onmessage = onBroadcastMessage
|
||||
|
||||
try {
|
||||
await migrate() // migrate old store
|
||||
const stored = await store.read() // check for new store
|
||||
if (!stored) {
|
||||
logger.debug('persisted state: initializing default storage')
|
||||
await store.write(defaults) // opt: init new store
|
||||
}
|
||||
_state = stored || defaults // return new store
|
||||
logger.debug('persisted state: initialized')
|
||||
} catch (e) {
|
||||
logger.error('persisted state: failed to load root state from storage', {
|
||||
message: e,
|
||||
})
|
||||
// AsyncStorage failure, but we can still continue in memory
|
||||
return defaults
|
||||
const stored = await readFromStorage()
|
||||
if (stored) {
|
||||
_state = stored
|
||||
}
|
||||
}
|
||||
init satisfies PersistedApi['init']
|
||||
|
||||
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
||||
return _state[key]
|
||||
}
|
||||
get satisfies PersistedApi['get']
|
||||
|
||||
export async function write<K extends keyof Schema>(
|
||||
key: K,
|
||||
value: Schema[K],
|
||||
): Promise<void> {
|
||||
_state = {
|
||||
..._state,
|
||||
[key]: value,
|
||||
}
|
||||
await writeToStorage(_state)
|
||||
}
|
||||
write satisfies PersistedApi['write']
|
||||
|
||||
export function onUpdate<K extends keyof Schema>(
|
||||
_key: K,
|
||||
_cb: (v: Schema[K]) => void,
|
||||
): () => void {
|
||||
return () => {}
|
||||
}
|
||||
onUpdate satisfies PersistedApi['onUpdate']
|
||||
|
||||
export async function clearStorage() {
|
||||
try {
|
||||
_state[key] = value
|
||||
await store.write(_state)
|
||||
// must happen on next tick, otherwise the tab will read stale storage data
|
||||
setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0)
|
||||
logger.debug(`persisted state: wrote root state to storage`, {
|
||||
updatedKey: key,
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(`persisted state: failed writing root state to storage`, {
|
||||
message: e,
|
||||
})
|
||||
await AsyncStorage.removeItem(BSKY_STORAGE)
|
||||
} catch (e: any) {
|
||||
logger.error(`persisted store: failed to clear`, {message: e.toString()})
|
||||
}
|
||||
}
|
||||
clearStorage satisfies PersistedApi['clearStorage']
|
||||
|
||||
export function onUpdate(cb: () => void): () => void {
|
||||
_emitter.addListener('update', cb)
|
||||
return () => _emitter.removeListener('update', cb)
|
||||
}
|
||||
|
||||
async function onBroadcastMessage({data}: MessageEvent) {
|
||||
// validate event
|
||||
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
|
||||
async function writeToStorage(value: Schema) {
|
||||
const rawData = tryStringify(value)
|
||||
if (rawData) {
|
||||
try {
|
||||
// read next state, possibly updated by another tab
|
||||
const next = await store.read()
|
||||
|
||||
if (next) {
|
||||
logger.debug(`persisted state: handling update from broadcast channel`)
|
||||
_state = next
|
||||
_emitter.emit('update')
|
||||
} else {
|
||||
logger.error(
|
||||
`persisted state: handled update update from broadcast channel, but found no data`,
|
||||
)
|
||||
}
|
||||
await AsyncStorage.setItem(BSKY_STORAGE, rawData)
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`persisted state: failed handling update from broadcast channel`,
|
||||
{
|
||||
message: e,
|
||||
},
|
||||
)
|
||||
logger.error(`persisted state: failed writing root state to storage`, {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readFromStorage(): Promise<Schema | undefined> {
|
||||
let rawData: string | null = null
|
||||
try {
|
||||
rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
||||
} catch (e) {
|
||||
logger.error(`persisted state: failed reading root state from storage`, {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
if (rawData) {
|
||||
return tryParse(rawData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
import BroadcastChannel from '#/lib/broadcast'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
defaults,
|
||||
Schema,
|
||||
tryParse,
|
||||
tryStringify,
|
||||
} from '#/state/persisted/schema'
|
||||
import {PersistedApi} from './types'
|
||||
|
||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||
export {defaults} from '#/state/persisted/schema'
|
||||
|
||||
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||
|
||||
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
||||
const UPDATE_EVENT = 'BSKY_UPDATE'
|
||||
|
||||
let _state: Schema = defaults
|
||||
const _emitter = new EventEmitter()
|
||||
|
||||
export async function init() {
|
||||
broadcast.onmessage = onBroadcastMessage
|
||||
const stored = readFromStorage()
|
||||
if (stored) {
|
||||
_state = stored
|
||||
}
|
||||
}
|
||||
init satisfies PersistedApi['init']
|
||||
|
||||
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
||||
return _state[key]
|
||||
}
|
||||
get satisfies PersistedApi['get']
|
||||
|
||||
export async function write<K extends keyof Schema>(
|
||||
key: K,
|
||||
value: Schema[K],
|
||||
): Promise<void> {
|
||||
const next = readFromStorage()
|
||||
if (next) {
|
||||
// The storage could have been updated by a different tab before this tab is notified.
|
||||
// Make sure this write is applied on top of the latest data in the storage as long as it's valid.
|
||||
_state = next
|
||||
// Don't fire the update listeners yet to avoid a loop.
|
||||
// If there was a change, we'll receive the broadcast event soon enough which will do that.
|
||||
}
|
||||
try {
|
||||
if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) {
|
||||
// Fast path for updates that are guaranteed to be noops.
|
||||
// This is good mostly because it avoids useless broadcasts to other tabs.
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore and go through the normal path.
|
||||
}
|
||||
_state = {
|
||||
..._state,
|
||||
[key]: value,
|
||||
}
|
||||
writeToStorage(_state)
|
||||
broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
|
||||
broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading
|
||||
}
|
||||
write satisfies PersistedApi['write']
|
||||
|
||||
export function onUpdate<K extends keyof Schema>(
|
||||
key: K,
|
||||
cb: (v: Schema[K]) => void,
|
||||
): () => void {
|
||||
const listener = () => cb(get(key))
|
||||
_emitter.addListener('update', listener) // Backcompat while upgrading
|
||||
_emitter.addListener('update:' + key, listener)
|
||||
return () => {
|
||||
_emitter.removeListener('update', listener) // Backcompat while upgrading
|
||||
_emitter.removeListener('update:' + key, listener)
|
||||
}
|
||||
}
|
||||
onUpdate satisfies PersistedApi['onUpdate']
|
||||
|
||||
export async function clearStorage() {
|
||||
try {
|
||||
localStorage.removeItem(BSKY_STORAGE)
|
||||
} catch (e: any) {
|
||||
// Expected on the web in private mode.
|
||||
}
|
||||
}
|
||||
clearStorage satisfies PersistedApi['clearStorage']
|
||||
|
||||
async function onBroadcastMessage({data}: MessageEvent) {
|
||||
if (
|
||||
typeof data === 'object' &&
|
||||
(data.event === UPDATE_EVENT || // Backcompat while upgrading
|
||||
data.event?.type === UPDATE_EVENT)
|
||||
) {
|
||||
// read next state, possibly updated by another tab
|
||||
const next = readFromStorage()
|
||||
if (next === _state) {
|
||||
return
|
||||
}
|
||||
if (next) {
|
||||
_state = next
|
||||
if (typeof data.event.key === 'string') {
|
||||
_emitter.emit('update:' + data.event.key)
|
||||
} else {
|
||||
_emitter.emit('update') // Backcompat while upgrading
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
`persisted state: handled update update from broadcast channel, but found no data`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeToStorage(value: Schema) {
|
||||
const rawData = tryStringify(value)
|
||||
if (rawData) {
|
||||
try {
|
||||
localStorage.setItem(BSKY_STORAGE, rawData)
|
||||
} catch (e) {
|
||||
// Expected on the web in private mode.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lastRawData: string | undefined
|
||||
let lastResult: Schema | undefined
|
||||
function readFromStorage(): Schema | undefined {
|
||||
let rawData: string | null = null
|
||||
try {
|
||||
rawData = localStorage.getItem(BSKY_STORAGE)
|
||||
} catch (e) {
|
||||
// Expected on the web in private mode.
|
||||
}
|
||||
if (rawData) {
|
||||
if (rawData === lastRawData) {
|
||||
return lastResult
|
||||
} else {
|
||||
const result = tryParse(rawData)
|
||||
lastRawData = rawData
|
||||
lastResult = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {defaults, Schema, schema} from '#/state/persisted/schema'
|
||||
import {read, write} from '#/state/persisted/store'
|
||||
|
||||
/**
|
||||
* The shape of the serialized data from our legacy Mobx store.
|
||||
*/
|
||||
export type LegacySchema = {
|
||||
shell: {
|
||||
colorMode: 'system' | 'light' | 'dark'
|
||||
}
|
||||
session: {
|
||||
data: {
|
||||
service: string
|
||||
did: `did:plc:${string}`
|
||||
} | null
|
||||
accounts: {
|
||||
service: string
|
||||
did: `did:plc:${string}`
|
||||
refreshJwt: string
|
||||
accessJwt: string
|
||||
handle: string
|
||||
email: string
|
||||
displayName: string
|
||||
aviUrl: string
|
||||
emailConfirmed: boolean
|
||||
}[]
|
||||
}
|
||||
me: {
|
||||
did: `did:plc:${string}`
|
||||
handle: string
|
||||
displayName: string
|
||||
description: string
|
||||
avatar: string
|
||||
}
|
||||
onboarding: {
|
||||
step: string
|
||||
}
|
||||
preferences: {
|
||||
primaryLanguage: string
|
||||
contentLanguages: string[]
|
||||
postLanguage: string
|
||||
postLanguageHistory: string[]
|
||||
contentLabels: {
|
||||
nsfw: string
|
||||
nudity: string
|
||||
suggestive: string
|
||||
gore: string
|
||||
hate: string
|
||||
spam: string
|
||||
impersonation: string
|
||||
}
|
||||
savedFeeds: string[]
|
||||
pinnedFeeds: string[]
|
||||
requireAltTextEnabled: boolean
|
||||
}
|
||||
invitedUsers: {
|
||||
seenDids: string[]
|
||||
copiedInvites: string[]
|
||||
}
|
||||
mutedThreads: {uris: string[]}
|
||||
reminders: {lastEmailConfirm?: string}
|
||||
}
|
||||
|
||||
const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root'
|
||||
|
||||
export function transform(legacy: Partial<LegacySchema>): Schema {
|
||||
return {
|
||||
colorMode: legacy.shell?.colorMode || defaults.colorMode,
|
||||
darkTheme: defaults.darkTheme,
|
||||
session: {
|
||||
accounts: legacy.session?.accounts || defaults.session.accounts,
|
||||
currentAccount:
|
||||
legacy.session?.accounts?.find(
|
||||
a => a.did === legacy.session?.data?.did,
|
||||
) || defaults.session.currentAccount,
|
||||
},
|
||||
reminders: {
|
||||
lastEmailConfirm:
|
||||
legacy.reminders?.lastEmailConfirm ||
|
||||
defaults.reminders.lastEmailConfirm,
|
||||
},
|
||||
languagePrefs: {
|
||||
primaryLanguage:
|
||||
legacy.preferences?.primaryLanguage ||
|
||||
defaults.languagePrefs.primaryLanguage,
|
||||
contentLanguages:
|
||||
legacy.preferences?.contentLanguages ||
|
||||
defaults.languagePrefs.contentLanguages,
|
||||
postLanguage:
|
||||
legacy.preferences?.postLanguage || defaults.languagePrefs.postLanguage,
|
||||
postLanguageHistory:
|
||||
legacy.preferences?.postLanguageHistory ||
|
||||
defaults.languagePrefs.postLanguageHistory,
|
||||
appLanguage:
|
||||
legacy.preferences?.primaryLanguage ||
|
||||
defaults.languagePrefs.appLanguage,
|
||||
},
|
||||
requireAltTextEnabled:
|
||||
legacy.preferences?.requireAltTextEnabled ||
|
||||
defaults.requireAltTextEnabled,
|
||||
mutedThreads: legacy.mutedThreads?.uris || defaults.mutedThreads,
|
||||
invites: {
|
||||
copiedInvites:
|
||||
legacy.invitedUsers?.copiedInvites || defaults.invites.copiedInvites,
|
||||
},
|
||||
onboarding: {
|
||||
step: legacy.onboarding?.step || defaults.onboarding.step,
|
||||
},
|
||||
hiddenPosts: defaults.hiddenPosts,
|
||||
externalEmbeds: defaults.externalEmbeds,
|
||||
lastSelectedHomeFeed: defaults.lastSelectedHomeFeed,
|
||||
pdsAddressHistory: defaults.pdsAddressHistory,
|
||||
disableHaptics: defaults.disableHaptics,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates legacy persisted state to new store if new store doesn't exist in
|
||||
* local storage AND old storage exists.
|
||||
*/
|
||||
export async function migrate() {
|
||||
logger.debug('persisted state: check need to migrate')
|
||||
|
||||
try {
|
||||
const rawLegacyData = await AsyncStorage.getItem(
|
||||
DEPRECATED_ROOT_STATE_STORAGE_KEY,
|
||||
)
|
||||
const newData = await read()
|
||||
const alreadyMigrated = Boolean(newData)
|
||||
|
||||
if (!alreadyMigrated && rawLegacyData) {
|
||||
logger.debug('persisted state: migrating legacy storage')
|
||||
|
||||
const legacyData = JSON.parse(rawLegacyData)
|
||||
const newData = transform(legacyData)
|
||||
const validate = schema.safeParse(newData)
|
||||
|
||||
if (validate.success) {
|
||||
await write(newData)
|
||||
logger.debug('persisted state: migrated legacy storage')
|
||||
} else {
|
||||
logger.error('persisted state: legacy data failed validation', {
|
||||
message: validate.error,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.debug('persisted state: no migration needed')
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(e, {
|
||||
message: 'persisted state: error migrating legacy storage',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearLegacyStorage() {
|
||||
try {
|
||||
await AsyncStorage.removeItem(DEPRECATED_ROOT_STATE_STORAGE_KEY)
|
||||
} catch (e: any) {
|
||||
logger.error(`persisted legacy store: failed to clear`, {
|
||||
message: e.toString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {z} from 'zod'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {deviceLocales} from '#/platform/detection'
|
||||
import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army'
|
||||
|
||||
@@ -43,7 +44,7 @@ const currentAccountSchema = accountSchema.extend({
|
||||
})
|
||||
export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
|
||||
|
||||
export const schema = z.object({
|
||||
const schema = z.object({
|
||||
colorMode: z.enum(['system', 'light', 'dark']),
|
||||
darkTheme: z.enum(['dim', 'dark']).optional(),
|
||||
session: z.object({
|
||||
@@ -90,6 +91,7 @@ export const schema = z.object({
|
||||
disableAutoplay: z.boolean().optional(),
|
||||
kawaii: z.boolean().optional(),
|
||||
hasCheckedForStarterPack: z.boolean().optional(),
|
||||
subtitlesEnabled: z.boolean().optional(),
|
||||
/** @deprecated */
|
||||
mutedThreads: z.array(z.string()),
|
||||
})
|
||||
@@ -132,4 +134,45 @@ export const defaults: Schema = {
|
||||
disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
|
||||
kawaii: false,
|
||||
hasCheckedForStarterPack: false,
|
||||
subtitlesEnabled: true,
|
||||
}
|
||||
|
||||
export function tryParse(rawData: string): Schema | undefined {
|
||||
let objData
|
||||
try {
|
||||
objData = JSON.parse(rawData)
|
||||
} catch (e) {
|
||||
logger.error('persisted state: failed to parse root state from storage', {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
if (!objData) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = schema.safeParse(objData)
|
||||
if (parsed.success) {
|
||||
return objData
|
||||
} else {
|
||||
const errors =
|
||||
parsed.error?.errors?.map(e => ({
|
||||
code: e.code,
|
||||
// @ts-ignore exists on some types
|
||||
expected: e?.expected,
|
||||
path: e.path?.join('.'),
|
||||
})) || []
|
||||
logger.error(`persisted store: data failed validation on read`, {errors})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function tryStringify(value: Schema): string | undefined {
|
||||
try {
|
||||
schema.parse(value)
|
||||
return JSON.stringify(value)
|
||||
} catch (e) {
|
||||
logger.error(`persisted state: failed stringifying root state`, {
|
||||
message: e,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {Schema, schema} from '#/state/persisted/schema'
|
||||
|
||||
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||
|
||||
export async function write(value: Schema) {
|
||||
schema.parse(value)
|
||||
await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value))
|
||||
}
|
||||
|
||||
export async function read(): Promise<Schema | undefined> {
|
||||
const rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
||||
const objData = rawData ? JSON.parse(rawData) : undefined
|
||||
|
||||
// new user
|
||||
if (!objData) return undefined
|
||||
|
||||
// existing user, validate
|
||||
const parsed = schema.safeParse(objData)
|
||||
|
||||
if (parsed.success) {
|
||||
return objData
|
||||
} else {
|
||||
const errors =
|
||||
parsed.error?.errors?.map(e => ({
|
||||
code: e.code,
|
||||
// @ts-ignore exists on some types
|
||||
expected: e?.expected,
|
||||
path: e.path?.join('.'),
|
||||
})) || []
|
||||
logger.error(`persisted store: data failed validation on read`, {errors})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function clear() {
|
||||
try {
|
||||
await AsyncStorage.removeItem(BSKY_STORAGE)
|
||||
} catch (e: any) {
|
||||
logger.error(`persisted store: failed to clear`, {message: e.toString()})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Schema} from './schema'
|
||||
|
||||
export type PersistedApi = {
|
||||
init(): Promise<void>
|
||||
get<K extends keyof Schema>(key: K): Schema[K]
|
||||
write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void>
|
||||
onUpdate<K extends keyof Schema>(
|
||||
key: K,
|
||||
cb: (v: Schema[K]) => void,
|
||||
): () => void
|
||||
clearStorage: () => Promise<void>
|
||||
}
|
||||
@@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('requireAltTextEnabled'))
|
||||
})
|
||||
return persisted.onUpdate(
|
||||
'requireAltTextEnabled',
|
||||
nextRequireAltTextEnabled => {
|
||||
setState(nextRequireAltTextEnabled)
|
||||
},
|
||||
)
|
||||
}, [setStateWrapped])
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(Boolean(persisted.get('disableAutoplay')))
|
||||
return persisted.onUpdate('disableAutoplay', nextDisableAutoplay => {
|
||||
setState(Boolean(nextDisableAutoplay))
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(Boolean(persisted.get('disableHaptics')))
|
||||
return persisted.onUpdate('disableHaptics', nextDisableHaptics => {
|
||||
setState(Boolean(nextDisableHaptics))
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('externalEmbeds'))
|
||||
return persisted.onUpdate('externalEmbeds', nextExternalEmbeds => {
|
||||
setState(nextExternalEmbeds)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -19,20 +19,15 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||
}
|
||||
}
|
||||
if (feedDesc.startsWith('feedgen')) {
|
||||
return [
|
||||
FeedTuner.dedupReposts,
|
||||
FeedTuner.preferredLangOnly(langPrefs.contentLanguages),
|
||||
]
|
||||
return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
|
||||
}
|
||||
if (feedDesc.startsWith('list')) {
|
||||
const feedTuners = []
|
||||
|
||||
let feedTuners = []
|
||||
if (feedDesc.endsWith('|as_following')) {
|
||||
// Same as Following tuners below, copypaste for now.
|
||||
feedTuners.push(FeedTuner.removeOrphans)
|
||||
if (preferences?.feedViewPrefs.hideReposts) {
|
||||
feedTuners.push(FeedTuner.removeReposts)
|
||||
} else {
|
||||
feedTuners.push(FeedTuner.dedupReposts)
|
||||
}
|
||||
if (preferences?.feedViewPrefs.hideReplies) {
|
||||
feedTuners.push(FeedTuner.removeReplies)
|
||||
@@ -46,18 +41,15 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
||||
}
|
||||
} else {
|
||||
feedTuners.push(FeedTuner.dedupReposts)
|
||||
feedTuners.push(FeedTuner.dedupThreads)
|
||||
}
|
||||
return feedTuners
|
||||
}
|
||||
if (feedDesc === 'following') {
|
||||
const feedTuners = []
|
||||
const feedTuners = [FeedTuner.removeOrphans]
|
||||
|
||||
if (preferences?.feedViewPrefs.hideReposts) {
|
||||
feedTuners.push(FeedTuner.removeReposts)
|
||||
} else {
|
||||
feedTuners.push(FeedTuner.dedupReposts)
|
||||
}
|
||||
if (preferences?.feedViewPrefs.hideReplies) {
|
||||
feedTuners.push(FeedTuner.removeReplies)
|
||||
@@ -71,6 +63,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
||||
}
|
||||
feedTuners.push(FeedTuner.dedupThreads)
|
||||
|
||||
return feedTuners
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('hiddenPosts'))
|
||||
return persisted.onUpdate('hiddenPosts', nextHiddenPosts => {
|
||||
setState(nextHiddenPosts)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('useInAppBrowser'))
|
||||
return persisted.onUpdate('useInAppBrowser', nextUseInAppBrowser => {
|
||||
setState(nextUseInAppBrowser)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser'
|
||||
import {Provider as KawaiiProvider} from './kawaii'
|
||||
import {Provider as LanguagesProvider} from './languages'
|
||||
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
|
||||
import {Provider as SubtitlesProvider} from './subtitles'
|
||||
import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
|
||||
|
||||
export {
|
||||
@@ -24,6 +25,7 @@ export {
|
||||
export * from './hidden-posts'
|
||||
export {useLabelDefinitions} from './label-defs'
|
||||
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
|
||||
export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles'
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
return (
|
||||
@@ -36,7 +38,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
<DisableHapticsProvider>
|
||||
<AutoplayProvider>
|
||||
<UsedStarterPacksProvider>
|
||||
<KawaiiProvider>{children}</KawaiiProvider>
|
||||
<SubtitlesProvider>
|
||||
<KawaiiProvider>{children}</KawaiiProvider>
|
||||
</SubtitlesProvider>
|
||||
</UsedStarterPacksProvider>
|
||||
</AutoplayProvider>
|
||||
</DisableHapticsProvider>
|
||||
|
||||
@@ -21,8 +21,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('kawaii'))
|
||||
return persisted.onUpdate('kawaii', nextKawaii => {
|
||||
setState(nextKawaii)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('languagePrefs'))
|
||||
return persisted.onUpdate('languagePrefs', nextLanguagePrefs => {
|
||||
setState(nextLanguagePrefs)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('largeAltBadgeEnabled'))
|
||||
})
|
||||
return persisted.onUpdate(
|
||||
'largeAltBadgeEnabled',
|
||||
nextLargeAltBadgeEnabled => {
|
||||
setState(nextLargeAltBadgeEnabled)
|
||||
},
|
||||
)
|
||||
}, [setStateWrapped])
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = boolean
|
||||
type SetContext = (v: boolean) => void
|
||||
|
||||
const stateContext = React.createContext<StateContext>(
|
||||
Boolean(persisted.defaults.subtitlesEnabled),
|
||||
)
|
||||
const setContext = React.createContext<SetContext>((_: boolean) => {})
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [state, setState] = React.useState(
|
||||
Boolean(persisted.get('subtitlesEnabled')),
|
||||
)
|
||||
|
||||
const setStateWrapped = React.useCallback(
|
||||
(subtitlesEnabled: persisted.Schema['subtitlesEnabled']) => {
|
||||
setState(Boolean(subtitlesEnabled))
|
||||
persisted.write('subtitlesEnabled', subtitlesEnabled)
|
||||
},
|
||||
[setState],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate('subtitlesEnabled', nextSubtitlesEnabled => {
|
||||
setState(Boolean(nextSubtitlesEnabled))
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
return (
|
||||
<stateContext.Provider value={state}>
|
||||
<setContext.Provider value={setStateWrapped}>
|
||||
{children}
|
||||
</setContext.Provider>
|
||||
</stateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useSubtitlesEnabled = () => React.useContext(stateContext)
|
||||
export const useSetSubtitlesEnabled = () => React.useContext(setContext)
|
||||
@@ -19,9 +19,12 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('hasCheckedForStarterPack'))
|
||||
})
|
||||
return persisted.onUpdate(
|
||||
'hasCheckedForStarterPack',
|
||||
nextHasCheckedForStarterPack => {
|
||||
setState(nextHasCheckedForStarterPack)
|
||||
},
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useModerationOpts} from '../../preferences/moderation-opts'
|
||||
import {STALE} from '..'
|
||||
@@ -59,7 +58,6 @@ export function useNotificationFeedQuery(opts?: {
|
||||
const moderationOpts = useModerationOpts()
|
||||
const unreads = useUnreadNotificationsApi()
|
||||
const enabled = opts?.enabled !== false
|
||||
const gate = useGate()
|
||||
|
||||
// false: force showing all notifications
|
||||
// undefined: let the server decide
|
||||
@@ -88,7 +86,6 @@ export function useNotificationFeedQuery(opts?: {
|
||||
queryClient,
|
||||
moderationOpts,
|
||||
fetchAdditionalData: true,
|
||||
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
||||
priority,
|
||||
})
|
||||
page = fetchedPage
|
||||
|
||||
@@ -8,7 +8,6 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
import BroadcastChannel from '#/lib/broadcast'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {resetBadgeCount} from 'lib/notifications/notifications'
|
||||
@@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const gate = useGate()
|
||||
|
||||
const [numUnread, setNumUnread] = React.useState('')
|
||||
|
||||
@@ -151,7 +149,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
// only fetch subjects when the page is going to be used
|
||||
// in the notifications query, otherwise skip it
|
||||
fetchAdditionalData: !!invalidate,
|
||||
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
||||
})
|
||||
const unreadCount = countUnread(page)
|
||||
const unreadCountStr =
|
||||
@@ -192,7 +189,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [setNumUnread, queryClient, moderationOpts, agent, gate])
|
||||
}, [setNumUnread, queryClient, moderationOpts, agent])
|
||||
checkUnreadRef.current = api.checkUnread
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,7 +30,6 @@ export async function fetchPage({
|
||||
queryClient,
|
||||
moderationOpts,
|
||||
fetchAdditionalData,
|
||||
shouldUngroupFollowBacks,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
cursor: string | undefined
|
||||
@@ -38,7 +37,6 @@ export async function fetchPage({
|
||||
queryClient: QueryClient
|
||||
moderationOpts: ModerationOpts | undefined
|
||||
fetchAdditionalData: boolean
|
||||
shouldUngroupFollowBacks?: () => boolean
|
||||
priority?: boolean
|
||||
}): Promise<{
|
||||
page: FeedPage
|
||||
@@ -58,7 +56,7 @@ export async function fetchPage({
|
||||
)
|
||||
|
||||
// group notifications which are essentially similar (follows, likes on a post)
|
||||
let notifsGrouped = groupNotifications(notifs, {shouldUngroupFollowBacks})
|
||||
let notifsGrouped = groupNotifications(notifs)
|
||||
|
||||
// we fetch subjects of notifications (usually posts) now instead of lazily
|
||||
// in the UI to avoid relayouts
|
||||
@@ -117,7 +115,6 @@ export function shouldFilterNotif(
|
||||
|
||||
export function groupNotifications(
|
||||
notifs: AppBskyNotificationListNotifications.Notification[],
|
||||
options?: {shouldUngroupFollowBacks?: () => boolean},
|
||||
): FeedNotification[] {
|
||||
const groupedNotifs: FeedNotification[] = []
|
||||
for (const notif of notifs) {
|
||||
@@ -137,9 +134,7 @@ export function groupNotifications(
|
||||
const prevIsFollowBack =
|
||||
groupedNotif.notification.reason === 'follow' &&
|
||||
groupedNotif.notification.author.viewer?.following
|
||||
const shouldUngroup =
|
||||
(nextIsFollowBack || prevIsFollowBack) &&
|
||||
options?.shouldUngroupFollowBacks?.()
|
||||
const shouldUngroup = nextIsFollowBack || prevIsFollowBack
|
||||
if (!shouldUngroup) {
|
||||
groupedNotif.additional = groupedNotif.additional || []
|
||||
groupedNotif.additional.push(notif)
|
||||
|
||||
@@ -77,11 +77,6 @@ export interface FeedPostSliceItem {
|
||||
uri: string
|
||||
post: AppBskyFeedDefs.PostView
|
||||
record: AppBskyFeedPost.Record
|
||||
reason?:
|
||||
| AppBskyFeedDefs.ReasonRepost
|
||||
| ReasonFeedSource
|
||||
| {[k: string]: unknown; $type: string}
|
||||
feedContext: string | undefined
|
||||
moderation: ModerationDecision
|
||||
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
||||
isParentBlocked?: boolean
|
||||
@@ -90,9 +85,14 @@ export interface FeedPostSliceItem {
|
||||
export interface FeedPostSlice {
|
||||
_isFeedPostSlice: boolean
|
||||
_reactKey: string
|
||||
rootUri: string
|
||||
isThread: boolean
|
||||
items: FeedPostSliceItem[]
|
||||
isIncompleteThread: boolean
|
||||
isFallbackMarker: boolean
|
||||
feedContext: string | undefined
|
||||
reason?:
|
||||
| AppBskyFeedDefs.ReasonRepost
|
||||
| ReasonFeedSource
|
||||
| {[k: string]: unknown; $type: string}
|
||||
}
|
||||
|
||||
export interface FeedPageUnselected {
|
||||
@@ -313,53 +313,22 @@ export function usePostFeedQuery(
|
||||
const feedPostSlice: FeedPostSlice = {
|
||||
_reactKey: slice._reactKey,
|
||||
_isFeedPostSlice: true,
|
||||
rootUri: slice.uri,
|
||||
isThread:
|
||||
slice.items.length > 1 &&
|
||||
slice.items.every(
|
||||
item =>
|
||||
item.post.author.did ===
|
||||
slice.items[0].post.author.did,
|
||||
),
|
||||
items: slice.items
|
||||
.map((item, i) => {
|
||||
if (
|
||||
AppBskyFeedPost.isRecord(item.post.record) &&
|
||||
AppBskyFeedPost.validateRecord(item.post.record)
|
||||
.success
|
||||
) {
|
||||
const parent = item.reply?.parent
|
||||
let parentAuthor:
|
||||
| AppBskyActorDefs.ProfileViewBasic
|
||||
| undefined
|
||||
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||
parentAuthor = parent.author
|
||||
}
|
||||
if (!parentAuthor) {
|
||||
parentAuthor =
|
||||
slice.items[i + 1]?.reply?.grandparentAuthor
|
||||
}
|
||||
const replyRef = item.reply
|
||||
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
|
||||
replyRef?.parent,
|
||||
)
|
||||
|
||||
const feedPostSliceItem: FeedPostSliceItem = {
|
||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||
uri: item.post.uri,
|
||||
post: item.post,
|
||||
record: item.post.record,
|
||||
reason: slice.reason,
|
||||
feedContext: slice.feedContext,
|
||||
moderation: moderations[i],
|
||||
parentAuthor,
|
||||
isParentBlocked,
|
||||
}
|
||||
return feedPostSliceItem
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
.filter(n => !!n),
|
||||
isIncompleteThread: slice.isIncompleteThread,
|
||||
isFallbackMarker: slice.isFallbackMarker,
|
||||
feedContext: slice.feedContext,
|
||||
reason: slice.reason,
|
||||
items: slice.items.map((item, i) => {
|
||||
const feedPostSliceItem: FeedPostSliceItem = {
|
||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||
uri: item.post.uri,
|
||||
post: item.post,
|
||||
record: item.record,
|
||||
moderation: moderations[i],
|
||||
parentAuthor: item.parentAuthor,
|
||||
isParentBlocked: item.isParentBlocked,
|
||||
}
|
||||
return feedPostSliceItem
|
||||
}),
|
||||
}
|
||||
return feedPostSlice
|
||||
})
|
||||
@@ -442,7 +411,6 @@ export async function pollLatest(page: FeedPage | undefined) {
|
||||
if (post) {
|
||||
const slices = page.tuner.tune([post], {
|
||||
dryRun: true,
|
||||
maintainOrder: true,
|
||||
})
|
||||
if (slices[0]) {
|
||||
return true
|
||||
|
||||
@@ -136,6 +136,8 @@ export function sortThread(
|
||||
node: ThreadNode,
|
||||
opts: UsePreferencesQueryResponse['threadViewPrefs'],
|
||||
modCache: ThreadModerationCache,
|
||||
currentDid: string | undefined,
|
||||
justPostedUris: Set<string>,
|
||||
): ThreadNode {
|
||||
if (node.type !== 'post') {
|
||||
return node
|
||||
@@ -149,6 +151,20 @@ export function sortThread(
|
||||
return -1
|
||||
}
|
||||
|
||||
if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) {
|
||||
const aIsJustPosted =
|
||||
a.post.author.did === currentDid && justPostedUris.has(a.post.uri)
|
||||
const bIsJustPosted =
|
||||
b.post.author.did === currentDid && justPostedUris.has(b.post.uri)
|
||||
if (aIsJustPosted && bIsJustPosted) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsJustPosted) {
|
||||
return -1 // reply while onscreen
|
||||
} else if (bIsJustPosted) {
|
||||
return 1 // reply while onscreen
|
||||
}
|
||||
}
|
||||
|
||||
const aIsByOp = a.post.author.did === node.post?.author.did
|
||||
const bIsByOp = b.post.author.did === node.post?.author.did
|
||||
if (aIsByOp && bIsByOp) {
|
||||
@@ -159,6 +175,16 @@ export function sortThread(
|
||||
return 1 // op's own reply
|
||||
}
|
||||
|
||||
const aIsBySelf = a.post.author.did === currentDid
|
||||
const bIsBySelf = b.post.author.did === currentDid
|
||||
if (aIsBySelf && bIsBySelf) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsBySelf) {
|
||||
return -1 // current account's reply
|
||||
} else if (bIsBySelf) {
|
||||
return 1 // current account's reply
|
||||
}
|
||||
|
||||
const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
|
||||
const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
|
||||
if (aBlur !== bBlur) {
|
||||
@@ -195,7 +221,9 @@ export function sortThread(
|
||||
}
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
||||
})
|
||||
node.replies.forEach(reply => sortThread(reply, opts, modCache))
|
||||
node.replies.forEach(reply =>
|
||||
sortThread(reply, opts, modCache, currentDid, justPostedUris),
|
||||
)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
@@ -40,18 +40,10 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
|
||||
pages: data.pages.map(page => {
|
||||
return {
|
||||
...page,
|
||||
lists: page.lists
|
||||
/*
|
||||
* Starter packs use a reference list, which we do not want to
|
||||
* show on profiles. At some point we could probably just filter
|
||||
* this out on the backend instead of in the client.
|
||||
*/
|
||||
.filter(l => l.purpose !== 'app.bsky.graph.defs#referencelist')
|
||||
// filter by labels
|
||||
.filter(list => {
|
||||
const decision = moderateUserList(list, moderationOpts!)
|
||||
return !decision.ui('contentList').filter
|
||||
}),
|
||||
lists: page.lists.filter(list => {
|
||||
const decision = moderateUserList(list, moderationOpts!)
|
||||
return !decision.ui('contentList').filter
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -222,6 +222,7 @@ export function useProfileFollowMutationQueue(
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext'],
|
||||
) {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const did = profile.did
|
||||
const initialFollowingUri = profile.viewer?.following
|
||||
@@ -253,6 +254,20 @@ export function useProfileFollowMutationQueue(
|
||||
updateProfileShadow(queryClient, did, {
|
||||
followingUri: finalFollowingUri,
|
||||
})
|
||||
|
||||
if (finalFollowingUri) {
|
||||
agent.app.bsky.graph
|
||||
.getSuggestedFollowsByActor({
|
||||
actor: did,
|
||||
})
|
||||
.then(res => {
|
||||
const dids = res.data.suggestions
|
||||
.filter(a => !a.viewer?.following)
|
||||
.map(a => a.did)
|
||||
.slice(0, 8)
|
||||
userActionHistory.followSuggestion(dids)
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
||||
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
|
||||
const agent = useAgent()
|
||||
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
|
||||
gcTime: 0,
|
||||
queryKey: suggestedFollowsByActorQueryKey(did),
|
||||
queryFn: async () => {
|
||||
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
|
||||
|
||||
@@ -2,10 +2,11 @@ import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {CompressedVideo} from 'lib/media/video/compress'
|
||||
import {UploadVideoResponse} from 'lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from 'state/queries/video/util'
|
||||
import {useSession} from 'state/session'
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {UploadVideoResponse} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
|
||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
@@ -18,6 +19,7 @@ export const useUploadVideoMutation = ({
|
||||
setProgress: (progress: number) => void
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (video: CompressedVideo) => {
|
||||
@@ -26,6 +28,17 @@ export const useUploadVideoMutation = ({
|
||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
||||
})
|
||||
|
||||
// a logged-in agent should have this set, but we'll check just in case
|
||||
if (!agent.pdsUrl) {
|
||||
throw new Error('Agent does not have a PDS URL')
|
||||
}
|
||||
|
||||
const {data: serviceAuth} =
|
||||
await agent.api.com.atproto.server.getServiceAuth({
|
||||
aud: `did:web:${agent.pdsUrl.hostname}`,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
})
|
||||
|
||||
const uploadTask = createUploadTask(
|
||||
uri,
|
||||
video.uri,
|
||||
@@ -33,13 +46,12 @@ export const useUploadVideoMutation = ({
|
||||
headers: {
|
||||
'dev-key': UPLOAD_HEADER,
|
||||
'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4?
|
||||
Authorization: `Bearer ${serviceAuth.token}`,
|
||||
},
|
||||
httpMethod: 'POST',
|
||||
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||
},
|
||||
p => {
|
||||
setProgress(p.totalBytesSent / p.totalBytesExpectedToSend)
|
||||
},
|
||||
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
|
||||
)
|
||||
const res = await uploadTask.uploadAsync()
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {CompressedVideo} from 'lib/media/video/compress'
|
||||
import {UploadVideoResponse} from 'lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from 'state/queries/video/util'
|
||||
import {useSession} from 'state/session'
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {UploadVideoResponse} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
|
||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
@@ -17,6 +18,7 @@ export const useUploadVideoMutation = ({
|
||||
setProgress: (progress: number) => void
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (video: CompressedVideo) => {
|
||||
@@ -25,6 +27,17 @@ export const useUploadVideoMutation = ({
|
||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
||||
})
|
||||
|
||||
// a logged-in agent should have this set, but we'll check just in case
|
||||
if (!agent.pdsUrl) {
|
||||
throw new Error('Agent does not have a PDS URL')
|
||||
}
|
||||
|
||||
const {data: serviceAuth} =
|
||||
await agent.api.com.atproto.server.getServiceAuth({
|
||||
aud: `did:web:${agent.pdsUrl.hostname}`,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
})
|
||||
|
||||
const bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
@@ -53,6 +66,7 @@ export const useUploadVideoMutation = ({
|
||||
xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type?
|
||||
// @TODO remove this header for prod
|
||||
xhr.setRequestHeader('dev-key', UPLOAD_HEADER)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
||||
xhr.send(bytes)
|
||||
})) as UploadVideoResponse
|
||||
|
||||
|
||||
@@ -185,8 +185,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}, [state])
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
const synced = persisted.get('session')
|
||||
return persisted.onUpdate('session', nextSession => {
|
||||
const synced = nextSession
|
||||
addSessionDebugLog({type: 'persisted:receive', data: synced})
|
||||
dispatch({
|
||||
type: 'synced-accounts',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = {
|
||||
@@ -43,10 +44,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setColorMode(persisted.get('colorMode'))
|
||||
setDarkTheme(persisted.get('darkTheme'))
|
||||
const unsub1 = persisted.onUpdate('darkTheme', nextDarkTheme => {
|
||||
setDarkTheme(nextDarkTheme)
|
||||
})
|
||||
const unsub2 = persisted.onUpdate('colorMode', nextColorMode => {
|
||||
setColorMode(nextColorMode)
|
||||
})
|
||||
return () => {
|
||||
unsub1()
|
||||
unsub2()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyRichtextFacet,
|
||||
ModerationDecision,
|
||||
AppBskyActorDefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
|
||||
export interface ComposerOptsPostRef {
|
||||
@@ -31,7 +32,7 @@ export interface ComposerOptsQuote {
|
||||
}
|
||||
export interface ComposerOpts {
|
||||
replyTo?: ComposerOptsPostRef
|
||||
onPost?: () => void
|
||||
onPost?: (postUri: string | undefined) => void
|
||||
quote?: ComposerOptsQuote
|
||||
mention?: string // handle of user to mention
|
||||
openPicker?: (pos: DOMRect | undefined) => void
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
export const OnboardingScreenSteps = {
|
||||
Welcome: 'Welcome',
|
||||
@@ -81,13 +82,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
const next = persisted.get('onboarding').step
|
||||
return persisted.onUpdate('onboarding', nextOnboarding => {
|
||||
const next = nextOnboarding.step
|
||||
// TODO we've introduced a footgun
|
||||
if (state.step !== next) {
|
||||
dispatch({
|
||||
type: 'set',
|
||||
step: persisted.get('onboarding').step as OnboardingStep,
|
||||
step: nextOnboarding.step as OnboardingStep,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {
|
||||
ProgressGuideToast,
|
||||
ProgressGuideToastRef,
|
||||
@@ -61,7 +60,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutateAsync, variables, isPending} =
|
||||
useSetActiveProgressGuideMutation()
|
||||
const gate = useGate()
|
||||
|
||||
const activeProgressGuide = (
|
||||
isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
|
||||
@@ -89,9 +87,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const controls = React.useMemo(() => {
|
||||
return {
|
||||
startProgressGuide(guide: ProgressGuideName) {
|
||||
if (!gate('new_user_progress_guide')) {
|
||||
return
|
||||
}
|
||||
if (guide === 'like-10-and-follow-7') {
|
||||
const guideObj = {
|
||||
guide: 'like-10-and-follow-7',
|
||||
@@ -148,7 +143,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
mutateAsync(guide?.isComplete ? undefined : guide)
|
||||
},
|
||||
}
|
||||
}, [activeProgressGuide, mutateAsync, gate, setLocalGuideState])
|
||||
}, [activeProgressGuide, mutateAsync, setLocalGuideState])
|
||||
|
||||
return (
|
||||
<ProgressGuideContext.Provider value={localGuideState}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
|
||||
const LIKE_WINDOW = 100
|
||||
const FOLLOW_WINDOW = 100
|
||||
const FOLLOW_SUGGESTION_WINDOW = 100
|
||||
const SEEN_WINDOW = 100
|
||||
|
||||
export type SeenPost = {
|
||||
@@ -22,6 +23,10 @@ export type UserActionHistory = {
|
||||
* The last 100 DIDs the user has followed
|
||||
*/
|
||||
follows: string[]
|
||||
/*
|
||||
* The last 100 DIDs of suggested follows based on last follows
|
||||
*/
|
||||
followSuggestions: string[]
|
||||
/**
|
||||
* The last 100 post URIs the user has seen from the Discover feed only
|
||||
*/
|
||||
@@ -31,6 +36,7 @@ export type UserActionHistory = {
|
||||
const userActionHistory: UserActionHistory = {
|
||||
likes: [],
|
||||
follows: [],
|
||||
followSuggestions: [],
|
||||
seen: [],
|
||||
}
|
||||
|
||||
@@ -58,6 +64,13 @@ export function follow(dids: string[]) {
|
||||
.concat(dids)
|
||||
.slice(-FOLLOW_WINDOW)
|
||||
}
|
||||
|
||||
export function followSuggestion(dids: string[]) {
|
||||
userActionHistory.followSuggestions = userActionHistory.followSuggestions
|
||||
.concat(dids)
|
||||
.slice(-FOLLOW_SUGGESTION_WINDOW)
|
||||
}
|
||||
|
||||
export function unfollow(dids: string[]) {
|
||||
userActionHistory.follows = userActionHistory.follows.filter(
|
||||
uri => !dids.includes(uri),
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {s} from '#/lib/styles'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {
|
||||
useLoggedOutView,
|
||||
useLoggedOutViewControls,
|
||||
} from '#/state/shell/logged-out'
|
||||
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {Login} from '#/screens/Login'
|
||||
import {Signup} from '#/screens/Signup'
|
||||
import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
|
||||
@@ -34,7 +29,6 @@ enum ScreenState {
|
||||
export {ScreenState as LoggedOutScreenState}
|
||||
|
||||
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
const {hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
@@ -52,10 +46,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
}
|
||||
})
|
||||
const {clearRequestedAccount} = useLoggedOutViewControls()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const gate = useGate()
|
||||
|
||||
const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount
|
||||
React.useEffect(() => {
|
||||
screen('Login')
|
||||
setMinimalShellMode(true)
|
||||
@@ -68,10 +59,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
clearRequestedAccount()
|
||||
}, [clearRequestedAccount, onDismiss])
|
||||
|
||||
const onPressSearch = React.useCallback(() => {
|
||||
navigation.navigate(`SearchTab`)
|
||||
}, [navigation])
|
||||
|
||||
return (
|
||||
<View testID="noSessionView" style={[s.hContentRegion, pal.view]}>
|
||||
<ErrorBoundary>
|
||||
@@ -98,39 +85,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
) : isNative &&
|
||||
!hasSession &&
|
||||
isFirstScreen &&
|
||||
!gate('native_pwi_disabled') ? (
|
||||
<Pressable
|
||||
accessibilityHint={_(msg`Search for users`)}
|
||||
accessibilityLabel={_(msg`Search for users`)}
|
||||
accessibilityRole="button"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
right: 20,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
zIndex: 100,
|
||||
backgroundColor: pal.btn.backgroundColor,
|
||||
borderRadius: 100,
|
||||
}}
|
||||
onPress={onPressSearch}>
|
||||
<Text type="lg-bold" style={[pal.text]}>
|
||||
<Trans>Search</Trans>{' '}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="search"
|
||||
size={16}
|
||||
style={{
|
||||
color: String(pal.text.color),
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{screenState === ScreenState.S_StarterPack ? (
|
||||
|
||||
@@ -392,7 +392,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
emitPostCreated()
|
||||
}
|
||||
setLangPrefs.savePostLanguageToHistory()
|
||||
onPost?.()
|
||||
onPost?.(postUri)
|
||||
onClose()
|
||||
Toast.show(
|
||||
replyTo
|
||||
|
||||