diff --git a/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg b/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..a9532cd9c6 --- /dev/null +++ b/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg b/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..9b92e533eb --- /dev/null +++ b/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg b/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..9987b34406 --- /dev/null +++ b/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg b/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..36d8e1d67c --- /dev/null +++ b/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/cc_filled_stroke2_corner0_rounded.svg b/assets/icons/cc_filled_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..58823ca80d --- /dev/null +++ b/assets/icons/cc_filled_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/cc_stroke2_corner0_rounded.svg b/assets/icons/cc_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..fcda1570f9 --- /dev/null +++ b/assets/icons/cc_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/pause_filled_corner0_rounded.svg b/assets/icons/pause_filled_corner0_rounded.svg new file mode 100644 index 0000000000..0037701f90 --- /dev/null +++ b/assets/icons/pause_filled_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/pause_filled_corner2_rounded.svg b/assets/icons/pause_filled_corner2_rounded.svg new file mode 100644 index 0000000000..98726d873e --- /dev/null +++ b/assets/icons/pause_filled_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/pause_stroke2_corner0_rounded.svg b/assets/icons/pause_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..d2735ed2bd --- /dev/null +++ b/assets/icons/pause_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/pause_stroke2_corner2_rounded.svg b/assets/icons/pause_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..3a8c0b4379 --- /dev/null +++ b/assets/icons/pause_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/play_filled_corner0_rounded.svg b/assets/icons/play_filled_corner0_rounded.svg new file mode 100644 index 0000000000..7bee1ae9a3 --- /dev/null +++ b/assets/icons/play_filled_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/play_stroke2_corner0_rounded.svg b/assets/icons/play_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..d7321b9b7b --- /dev/null +++ b/assets/icons/play_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go index 908486aa7e..d9235afdee 100644 --- a/bskyweb/cmd/bskyweb/main.go +++ b/bskyweb/cmd/bskyweb/main.go @@ -41,10 +41,10 @@ func run(args []string) { EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"}, }, &cli.StringFlag{ - Name: "ogcard-host", - Usage: "scheme, hostname, and port of ogcard service", + Name: "ogcard-host", + Usage: "scheme, hostname, and port of ogcard service", Required: false, - EnvVars: []string{"OGCARD_HOST"}, + EnvVars: []string{"OGCARD_HOST"}, }, &cli.StringFlag{ Name: "http-address", @@ -67,6 +67,13 @@ func run(args []string) { Required: false, EnvVars: []string{"DEBUG"}, }, + &cli.StringFlag{ + Name: "basic-auth-password", + Usage: "optional password to restrict access to web interface", + Required: false, + Value: "", + EnvVars: []string{"BASIC_AUTH_PASSWORD"}, + }, }, }, } diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 8da291fe56..fdef01ce78 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/subtle" "errors" "fmt" "io/fs" @@ -48,6 +49,7 @@ func serve(cctx *cli.Context) error { appviewHost := cctx.String("appview-host") ogcardHost := cctx.String("ogcard-host") linkHost := cctx.String("link-host") + basicAuthPassword := cctx.String("basic-auth-password") // Echo e := echo.New() @@ -140,6 +142,18 @@ func serve(cctx *cli.Context) error { }, })) + // optional password gating of entire web interface + if basicAuthPassword != "" { + e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) { + // Be careful to use constant time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(username), []byte("admin")) == 1 && + subtle.ConstantTimeCompare([]byte(password), []byte(basicAuthPassword)) == 1 { + return true, nil + } + return false, nil + })) + } + // redirect trailing slash to non-trailing slash. // all of our current endpoints have no trailing slash. e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{ diff --git a/jest/jestSetup.js b/jest/jestSetup.js index a6b7c24f69..a68c1dc4bf 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -95,3 +95,16 @@ jest.mock('expo-application', () => ({ nativeApplicationVersion: '1.0.0', nativeBuildVersion: '1', })) + +jest.mock('expo-modules-core', () => ({ + requireNativeModule: jest.fn().mockImplementation(moduleName => { + if (moduleName === 'ExpoPlatformInfo') { + return { + getIsReducedMotionEnabled: () => false, + } + } + }), + requireNativeViewManager: jest.fn().mockImplementation(moduleName => { + return () => null + }), +})) diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt new file mode 100644 index 0000000000..189796f817 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt @@ -0,0 +1,24 @@ +package expo.modules.blueskyswissarmy.platforminfo + +import android.provider.Settings +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoPlatformInfoModule : Module() { + override fun definition() = + ModuleDefinition { + Name("ExpoPlatformInfo") + + // See https://github.com/software-mansion/react-native-reanimated/blob/7df5fd57d608fe25724608835461cd925ff5151d/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeProxy/NativeProxyCommon.java#L242 + Function("getIsReducedMotionEnabled") { + val resolver = appContext.reactContext?.contentResolver ?: return@Function false + val scale = Settings.Global.getString(resolver, Settings.Global.TRANSITION_ANIMATION_SCALE) ?: return@Function false + + try { + return@Function scale.toFloat() == 0f + } catch (_: Error) { + return@Function false + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt new file mode 100644 index 0000000000..ddbb05cde3 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt @@ -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 + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt new file mode 100644 index 0000000000..a55ab80d5a --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt @@ -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 + } +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt new file mode 100644 index 0000000000..ec1e49816a --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt @@ -0,0 +1,82 @@ +package expo.modules.blueskyswissarmy.visibilityview + +import android.graphics.Rect + +class VisibilityViewManager { + companion object { + private val views = HashMap() + 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 + } + } +} diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json index 1111f8a0be..4cdc11e993 100644 --- a/modules/expo-bluesky-swiss-army/expo-module.config.json +++ b/modules/expo-bluesky-swiss-army/expo-module.config.json @@ -1,12 +1,19 @@ { "platforms": ["ios", "tvos", "android", "web"], "ios": { - "modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"] + "modules": [ + "ExpoBlueskySharedPrefsModule", + "ExpoBlueskyReferrerModule", + "ExpoBlueskyVisibilityViewModule", + "ExpoPlatformInfoModule" + ] }, "android": { "modules": [ "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule", - "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule" + "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule", + "expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule", + "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule" ] } } diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts index 89cea00a28..ebd67913e0 100644 --- a/modules/expo-bluesky-swiss-army/index.ts +++ b/modules/expo-bluesky-swiss-army/index.ts @@ -1,4 +1,6 @@ +import * as PlatformInfo from './src/PlatformInfo' import * as Referrer from './src/Referrer' import * as SharedPrefs from './src/SharedPrefs' +import VisibilityView from './src/VisibilityView' -export {Referrer, SharedPrefs} +export {PlatformInfo, Referrer, SharedPrefs, VisibilityView} diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift new file mode 100644 index 0000000000..4a1e6d7e7d --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift @@ -0,0 +1,11 @@ +import ExpoModulesCore + +public class ExpoPlatformInfoModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoPlatformInfo") + + Function("getIsReducedMotionEnabled") { + return UIAccessibility.isReduceMotionEnabled + } + } +} diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift new file mode 100644 index 0000000000..ec12a84af7 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift @@ -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 + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift new file mode 100644 index 0000000000..ae8e168681 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift @@ -0,0 +1,86 @@ +import Foundation + +class VisibilityViewManager { + static let shared = VisibilityViewManager() + + private let views = NSHashTable(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 + } +} diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift new file mode 100644 index 0000000000..fd99ee4938 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift @@ -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 + } +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts new file mode 100644 index 0000000000..e05f173d64 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts @@ -0,0 +1,7 @@ +import {requireNativeModule} from 'expo-modules-core' + +const NativeModule = requireNativeModule('ExpoPlatformInfo') + +export function getIsReducedMotionEnabled(): boolean { + return NativeModule.getIsReducedMotionEnabled() +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts new file mode 100644 index 0000000000..9b9b7fc0c7 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts @@ -0,0 +1,5 @@ +import {NotImplementedError} from '../NotImplemented' + +export function getIsReducedMotionEnabled(): boolean { + throw new NotImplementedError() +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts new file mode 100644 index 0000000000..c7ae6b7cd4 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts @@ -0,0 +1,6 @@ +export function getIsReducedMotionEnabled(): boolean { + if (typeof window === 'undefined') { + return false + } + return window.matchMedia('(prefers-reduced-motion: reduce)').matches +} diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx new file mode 100644 index 0000000000..9d0e8cf220 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx @@ -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 +}> = 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 ( + + {children} + + ) +} diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx new file mode 100644 index 0000000000..8b4f1928cb --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx @@ -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 +} diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts b/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts new file mode 100644 index 0000000000..312acf2d29 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts @@ -0,0 +1,6 @@ +import React from 'react' +export interface VisibilityViewProps { + children: React.ReactNode + onChangeStatus: (isActive: boolean) => void + enabled: boolean +} diff --git a/package.json b/package.json index 3d053bc83b..faeee448c9 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "^0.12.26", + "@atproto/api": "0.12.29", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/patches/react-native-reanimated+3.11.0.patch b/patches/react-native-reanimated+3.11.0.patch index 9147cf08ef..a79a0ac085 100644 --- a/patches/react-native-reanimated+3.11.0.patch +++ b/patches/react-native-reanimated+3.11.0.patch @@ -207,31 +207,3 @@ index 88b3fdf..2488ebc 100644 const { layout, entering, exiting, sharedTransitionTag } = this.props; if ( -diff --git a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js -index ac9be5d..86d4605 100644 ---- a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js -+++ b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js -@@ -47,4 +47,5 @@ export { LayoutAnimationConfig } from './component/LayoutAnimationConfig'; - export { PerformanceMonitor } from './component/PerformanceMonitor'; - export { startMapper, stopMapper } from './mappers'; - export { startScreenTransition, finishScreenTransition, ScreenTransition } from './screenTransition'; -+export { isReducedMotion } from './PlatformChecker'; - //# sourceMappingURL=index.js.map -diff --git a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts -index f01dc57..161ef22 100644 ---- a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts -+++ b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts -@@ -36,3 +36,4 @@ export type { FlatListPropsWithLayout } from './component/FlatList'; - export { startMapper, stopMapper } from './mappers'; - export { startScreenTransition, finishScreenTransition, ScreenTransition, } from './screenTransition'; - export type { AnimatedScreenTransition, GoBackGesture, ScreenTransitionConfig, } from './screenTransition'; -+export { isReducedMotion } from './PlatformChecker'; -diff --git a/node_modules/react-native-reanimated/src/reanimated2/index.ts b/node_modules/react-native-reanimated/src/reanimated2/index.ts -index 5885fa1..a3c693f 100644 ---- a/node_modules/react-native-reanimated/src/reanimated2/index.ts -+++ b/node_modules/react-native-reanimated/src/reanimated2/index.ts -@@ -284,3 +284,4 @@ export type { - GoBackGesture, - ScreenTransitionConfig, - } from './screenTransition'; -+export { isReducedMotion } from './PlatformChecker'; diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 2e8724143d..e37d2c3e0e 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -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]) diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index e706e101f5..beeb554763 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -122,8 +122,16 @@ export function ListHeaderDesktop({ if (!gtTablet) return null return ( - - {title} + + {title} {subtitle ? ( {subtitle} diff --git a/src/components/icons/ArrowsDiagonal.tsx b/src/components/icons/ArrowsDiagonal.tsx new file mode 100644 index 0000000000..3f9ae40e0f --- /dev/null +++ b/src/components/icons/ArrowsDiagonal.tsx @@ -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', +}) diff --git a/src/components/icons/CC.tsx b/src/components/icons/CC.tsx new file mode 100644 index 0000000000..da2e7c5dba --- /dev/null +++ b/src/components/icons/CC.tsx @@ -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', +}) diff --git a/src/components/icons/Pause.tsx b/src/components/icons/Pause.tsx new file mode 100644 index 0000000000..927f285a00 --- /dev/null +++ b/src/components/icons/Pause.tsx @@ -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', +}) diff --git a/src/components/icons/Play.tsx b/src/components/icons/Play.tsx index acf421d57c..176b24f281 100644 --- a/src/components/icons/Play.tsx +++ b/src/components/icons/Play.tsx @@ -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', }) diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx index 45122a4efe..f2d13f6424 100644 --- a/src/components/moderation/ContentHider.tsx +++ b/src/components/moderation/ContentHider.tsx @@ -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 ( - + {children} ) @@ -163,21 +163,3 @@ export function ContentHider({ ) } - -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', - }, -}) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 7ddb79434a..61de795a14 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -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 = new Set() seenUris: Set = new Set() + seenRootUris: Set = 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 = 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) } diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index 86db1b98fa..b41e82fb06 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -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 diff --git a/src/lib/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts index e2d5ae5e70..1ccb4c65b4 100644 --- a/src/lib/moderation/useModerationCauseDescription.ts +++ b/src/lib/moderation/useModerationCauseDescription.ts @@ -134,7 +134,7 @@ export function useModerationCauseDescription( } } if (def.identifier === 'porn' || def.identifier === 'sexual') { - strings.name = 'Adult Content' + strings.name = _(msg`Adult Content`) } return { diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index fafc66143a..88b9eee3a5 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -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) { diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 159061eac9..9a427ad40f 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -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'} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 58a60232be..5ae6bd5300 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -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' diff --git a/src/platform/detection.ts b/src/platform/detection.ts index 0c0360a82a..c62ae71aae 100644 --- a/src/platform/detection.ts +++ b/src/platform/detection.ts @@ -1,5 +1,4 @@ import {Platform} from 'react-native' -import {isReducedMotion} from 'react-native-reanimated' import {getLocales} from 'expo-localization' import {fixLegacyLanguageCode} from '#/locale/helpers' @@ -15,11 +14,10 @@ export const isMobileWeb = isWeb && // @ts-ignore we know window exists -prf global.window.matchMedia(isMobileWebMediaQuery)?.matches +export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent) export const deviceLocales = dedupArray( getLocales?.() .map?.(locale => fixLegacyLanguageCode(locale.languageCode)) .filter(code => typeof code === 'string'), ) as string[] - -export const prefersReducedMotion = isReducedMotion() diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index 11b951e99d..c0e78e9789 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -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 diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index b275b31910..2b6353b276 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -157,7 +157,7 @@ let ProfileHeaderStandard = ({ hideBackButton={hideBackButton} isPlaceholderProfile={isPlaceholderProfile}> {(isScrolledDown || hasNew) && ( ) { const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() => - isReducedMotion(), + PlatformInfo.getIsReducedMotionEnabled(), ) const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false) diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index 59b4bf78a4..aab2737e5a 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -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() diff --git a/src/state/invites.tsx b/src/state/invites.tsx index 6a0d1b5900..0d40caf258 100644 --- a/src/state/invites.tsx +++ b/src/state/invites.tsx @@ -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]) diff --git a/src/state/persisted/__tests__/fixtures.ts b/src/state/persisted/__tests__/fixtures.ts deleted file mode 100644 index ac8f7c8d1d..0000000000 --- a/src/state/persisted/__tests__/fixtures.ts +++ /dev/null @@ -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: {}, -} diff --git a/src/state/persisted/__tests__/index.test.ts b/src/state/persisted/__tests__/index.test.ts deleted file mode 100644 index 90c5e0e4ec..0000000000 --- a/src/state/persisted/__tests__/index.test.ts +++ /dev/null @@ -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') -}) diff --git a/src/state/persisted/__tests__/migrate.test.ts b/src/state/persisted/__tests__/migrate.test.ts deleted file mode 100644 index 97767e2732..0000000000 --- a/src/state/persisted/__tests__/migrate.test.ts +++ /dev/null @@ -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}, - ) -}) diff --git a/src/state/persisted/__tests__/schema.test.ts b/src/state/persisted/__tests__/schema.test.ts deleted file mode 100644 index c78a2c27cb..0000000000 --- a/src/state/persisted/__tests__/schema.test.ts +++ /dev/null @@ -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) -}) diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 5fe0f9bd0a..6f4beae2ca 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -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(key: K): Schema[K] { return _state[key] } +get satisfies PersistedApi['get'] export async function write( key: K, value: Schema[K], ): Promise { + _state = { + ..._state, + [key]: value, + } + await writeToStorage(_state) +} +write satisfies PersistedApi['write'] + +export function onUpdate( + _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 { + 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) + } +} diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts new file mode 100644 index 0000000000..7521776bc0 --- /dev/null +++ b/src/state/persisted/index.web.ts @@ -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(key: K): Schema[K] { + return _state[key] +} +get satisfies PersistedApi['get'] + +export async function write( + key: K, + value: Schema[K], +): Promise { + 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( + 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 + } + } +} diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts deleted file mode 100644 index ca7967cd2e..0000000000 --- a/src/state/persisted/legacy.ts +++ /dev/null @@ -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): 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(), - }) - } -} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 88fc370a6f..331a111a2e 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -1,6 +1,8 @@ import {z} from 'zod' -import {deviceLocales, prefersReducedMotion} from '#/platform/detection' +import {logger} from '#/logger' +import {deviceLocales} from '#/platform/detection' +import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army' const externalEmbedOptions = ['show', 'hide'] as const @@ -42,7 +44,7 @@ const currentAccountSchema = accountSchema.extend({ }) export type PersistedCurrentAccount = z.infer -export const schema = z.object({ +const schema = z.object({ colorMode: z.enum(['system', 'light', 'dark']), darkTheme: z.enum(['dim', 'dark']).optional(), session: z.object({ @@ -89,6 +91,7 @@ export const schema = z.object({ disableAutoplay: z.boolean().optional(), kawaii: z.boolean().optional(), hasCheckedForStarterPack: z.boolean().optional(), + subtitlesEnabled: z.boolean().optional(), /** @deprecated */ mutedThreads: z.array(z.string()), }) @@ -128,7 +131,48 @@ export const defaults: Schema = { lastSelectedHomeFeed: undefined, pdsAddressHistory: [], disableHaptics: false, - disableAutoplay: prefersReducedMotion, + disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(), kawaii: false, hasCheckedForStarterPack: false, + subtitlesEnabled: true, +} + +export function tryParse(rawData: string): Schema | undefined { + let objData + try { + objData = JSON.parse(rawData) + } catch (e) { + logger.error('persisted state: failed to parse root state from storage', { + message: e, + }) + } + if (!objData) { + return undefined + } + const parsed = schema.safeParse(objData) + if (parsed.success) { + return objData + } else { + const errors = + parsed.error?.errors?.map(e => ({ + code: e.code, + // @ts-ignore exists on some types + expected: e?.expected, + path: e.path?.join('.'), + })) || [] + logger.error(`persisted store: data failed validation on read`, {errors}) + return undefined + } +} + +export function tryStringify(value: Schema): string | undefined { + try { + schema.parse(value) + return JSON.stringify(value) + } catch (e) { + logger.error(`persisted state: failed stringifying root state`, { + message: e, + }) + return undefined + } } diff --git a/src/state/persisted/store.ts b/src/state/persisted/store.ts deleted file mode 100644 index f740126c45..0000000000 --- a/src/state/persisted/store.ts +++ /dev/null @@ -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 { - 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()}) - } -} diff --git a/src/state/persisted/types.ts b/src/state/persisted/types.ts new file mode 100644 index 0000000000..fd39079bf8 --- /dev/null +++ b/src/state/persisted/types.ts @@ -0,0 +1,12 @@ +import type {Schema} from './schema' + +export type PersistedApi = { + init(): Promise + get(key: K): Schema[K] + write(key: K, value: Schema[K]): Promise + onUpdate( + key: K, + cb: (v: Schema[K]) => void, + ): () => void + clearStorage: () => Promise +} diff --git a/src/state/preferences/alt-text-required.tsx b/src/state/preferences/alt-text-required.tsx index 642e790fbc..0ddc173ea3 100644 --- a/src/state/preferences/alt-text-required.tsx +++ b/src/state/preferences/alt-text-required.tsx @@ -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 ( diff --git a/src/state/preferences/autoplay.tsx b/src/state/preferences/autoplay.tsx index d5aa049f36..141c8161ef 100644 --- a/src/state/preferences/autoplay.tsx +++ b/src/state/preferences/autoplay.tsx @@ -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]) diff --git a/src/state/preferences/disable-haptics.tsx b/src/state/preferences/disable-haptics.tsx index af2c55a182..367d4f7db4 100644 --- a/src/state/preferences/disable-haptics.tsx +++ b/src/state/preferences/disable-haptics.tsx @@ -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]) diff --git a/src/state/preferences/external-embeds-prefs.tsx b/src/state/preferences/external-embeds-prefs.tsx index 9ace5d940f..04afb89dd7 100644 --- a/src/state/preferences/external-embeds-prefs.tsx +++ b/src/state/preferences/external-embeds-prefs.tsx @@ -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]) diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx index d816bde649..b6f14fae7b 100644 --- a/src/state/preferences/feed-tuners.tsx +++ b/src/state/preferences/feed-tuners.tsx @@ -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 } diff --git a/src/state/preferences/hidden-posts.tsx b/src/state/preferences/hidden-posts.tsx index 2c6a373e15..510af713d3 100644 --- a/src/state/preferences/hidden-posts.tsx +++ b/src/state/preferences/hidden-posts.tsx @@ -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]) diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx index 73c4bbbe78..76c854105e 100644 --- a/src/state/preferences/in-app-browser.tsx +++ b/src/state/preferences/in-app-browser.tsx @@ -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]) diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index e6b53d5be0..c7eaf27261 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -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<{}>) { - {children} + + {children} + diff --git a/src/state/preferences/kawaii.tsx b/src/state/preferences/kawaii.tsx index 4aa95ef8b0..4216891648 100644 --- a/src/state/preferences/kawaii.tsx +++ b/src/state/preferences/kawaii.tsx @@ -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]) diff --git a/src/state/preferences/languages.tsx b/src/state/preferences/languages.tsx index b7494c1f93..5093cd725d 100644 --- a/src/state/preferences/languages.tsx +++ b/src/state/preferences/languages.tsx @@ -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]) diff --git a/src/state/preferences/large-alt-badge.tsx b/src/state/preferences/large-alt-badge.tsx index b3d597c5cb..9d2c9fa54e 100644 --- a/src/state/preferences/large-alt-badge.tsx +++ b/src/state/preferences/large-alt-badge.tsx @@ -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 ( diff --git a/src/state/preferences/subtitles.tsx b/src/state/preferences/subtitles.tsx new file mode 100644 index 0000000000..e0e89feb16 --- /dev/null +++ b/src/state/preferences/subtitles.tsx @@ -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( + Boolean(persisted.defaults.subtitlesEnabled), +) +const setContext = React.createContext((_: 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 ( + + + {children} + + + ) +} + +export const useSubtitlesEnabled = () => React.useContext(stateContext) +export const useSetSubtitlesEnabled = () => React.useContext(setContext) diff --git a/src/state/preferences/used-starter-packs.tsx b/src/state/preferences/used-starter-packs.tsx index 8d5d9e8283..e4de479d55 100644 --- a/src/state/preferences/used-starter-packs.tsx +++ b/src/state/preferences/used-starter-packs.tsx @@ -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 ( diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 3054860db2..997076e819 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -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 diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index b5f7d0d60b..7bb325ea98 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -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 ( diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index 7651e414a4..e0ee02294e 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -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) diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 65467e8023..724043e586 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -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 diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index 70aa6eca08..5de3048527 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -137,6 +137,8 @@ export function sortThread( node: ThreadNode, opts: UsePreferencesQueryResponse['threadViewPrefs'], modCache: ThreadModerationCache, + currentDid: string | undefined, + justPostedUris: Set, threadgateRecord?: AppBskyFeedThreadgate.Record, ): ThreadNode { if (node.type !== 'post') { @@ -164,10 +166,20 @@ export function sortThread( return -1 } - /* - * Here, OP is actually whatever node is highlighted in the thread view, - * NOT necessarily the root post of the thread, though it can be. - */ + if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) { + const aIsJustPosted = + a.post.author.did === currentDid && justPostedUris.has(a.post.uri) + const bIsJustPosted = + b.post.author.did === currentDid && justPostedUris.has(b.post.uri) + if (aIsJustPosted && bIsJustPosted) { + return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest + } else if (aIsJustPosted) { + return -1 // reply while onscreen + } else if (bIsJustPosted) { + return 1 // reply while onscreen + } + } + const aIsByOp = a.post.author.did === node.post?.author.did const bIsByOp = b.post.author.did === node.post?.author.did if (aIsByOp && bIsByOp) { @@ -178,6 +190,16 @@ export function sortThread( return 1 // op's own reply } + const aIsBySelf = a.post.author.did === currentDid + const bIsBySelf = b.post.author.did === currentDid + if (aIsBySelf && bIsBySelf) { + return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest + } else if (aIsBySelf) { + return -1 // current account's reply + } else if (bIsBySelf) { + return 1 // current account's reply + } + const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur) const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur) if (aBlur !== bBlur) { @@ -215,7 +237,14 @@ export function sortThread( return b.post.indexedAt.localeCompare(a.post.indexedAt) }) node.replies.forEach(reply => - sortThread(reply, opts, modCache, threadgateRecord), + sortThread( + reply, + opts, + modCache, + currentDid, + justPostedUris, + threadgateRecord, + ), ) } return node diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index 75e3dd6e48..03c983ff80 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -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 + }), } }), } diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 1f866d26d2..6682cf3c89 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -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) + }) + } }, }) diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index a1244721a2..f5d51a974a 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -106,6 +106,7 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) { export function useSuggestedFollowsByActorQuery({did}: {did: string}) { const agent = useAgent() return useQuery({ + gcTime: 0, queryKey: suggestedFollowsByActorQueryKey(did), queryFn: async () => { const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts index 4d7f7995c5..cf741b2510 100644 --- a/src/state/queries/video/video-upload.ts +++ b/src/state/queries/video/video-upload.ts @@ -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() diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts index b5b9e93bf9..b9b0bacfac 100644 --- a/src/state/queries/video/video-upload.web.ts +++ b/src/state/queries/video/video-upload.web.ts @@ -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 diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 3aac19025d..09fcf86642 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -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', diff --git a/src/state/shell/color-mode.tsx b/src/state/shell/color-mode.tsx index f3339d2406..47b936c0bb 100644 --- a/src/state/shell/color-mode.tsx +++ b/src/state/shell/color-mode.tsx @@ -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 ( diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx index 5b4e505439..e28d6b4ac0 100644 --- a/src/state/shell/composer.tsx +++ b/src/state/shell/composer.tsx @@ -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 diff --git a/src/state/shell/onboarding.tsx b/src/state/shell/onboarding.tsx index 6a18b461f9..d3a8fec466 100644 --- a/src/state/shell/onboarding.tsx +++ b/src/state/shell/onboarding.tsx @@ -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, }) } }) diff --git a/src/state/shell/progress-guide.tsx b/src/state/shell/progress-guide.tsx index c9b42a263e..d64e9984f5 100644 --- a/src/state/shell/progress-guide.tsx +++ b/src/state/shell/progress-guide.tsx @@ -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 ( diff --git a/src/state/userActionHistory.ts b/src/state/userActionHistory.ts index d82b3723a4..8ffe7241e6 100644 --- a/src/state/userActionHistory.ts +++ b/src/state/userActionHistory.ts @@ -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), diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index cc71f648e9..cc57238053 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -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() - 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 ( @@ -98,39 +85,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { }} /> - ) : isNative && - !hasSession && - isFirstScreen && - !gate('native_pwi_disabled') ? ( - - - Search{' '} - - - ) : null} {screenState === ScreenState.S_StarterPack ? ( diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index ceec655e37..70bb5b2e97 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -392,7 +392,7 @@ export const ComposePost = observer(function ComposePost({ emitPostCreated() } setLangPrefs.savePostLanguageToHistory() - onPost?.() + onPost?.(postUri) onClose() Toast.show( replyTo diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index 6b38caff03..d4ba1f3a86 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -91,7 +91,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { type={replyTo.author.associated?.labeler ? 'labeler' : 'user'} /> - + {sanitizeDisplayName( replyTo.author.displayName || sanitizeHandle(replyTo.author.handle), )} diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 82755de1d3..bd39ddd843 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -194,6 +194,7 @@ export function Feed({ initialNumToRender={initialNumToRender} windowSize={11} sideBorders={false} + removeClippedSubviews={true} /> ) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index e4294eaa5f..b591b4b7fd 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -25,7 +25,6 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' -import {useGate} from '#/lib/statsig/statsig' import {parseTenorGif} from '#/lib/strings/embed-player' import {logger} from '#/logger' import {FeedNotification} from '#/state/queries/notifications/feed' @@ -87,7 +86,6 @@ let FeedItem = ({ const pal = usePalette('default') const {_} = useLingui() const t = useTheme() - const gate = useGate() const [isAuthorsExpanded, setAuthorsExpanded] = useState(false) const itemHref = useMemo(() => { if (item.type === 'post-like' || item.type === 'repost') { @@ -207,7 +205,7 @@ let FeedItem = ({ } } - if (isFollowBack && gate('ungroup_follow_backs')) { + if (isFollowBack) { action = _(msg`followed you back`) } else { action = _(msg`followed you`) @@ -255,6 +253,7 @@ let FeedItem = ({ borderColor: pal.colors.unreadNotifBorder, }, {borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth}, + a.overflow_hidden, ]} href={itemHref} noFeedback @@ -547,7 +546,7 @@ function ExpandedAuthorsList({ }, [heightInterp, visible]) return ( - + {visible && authors.map(author => ( { return item._reactKey } -export function PostThread({ - uri, - onCanReply, - onPressReply, -}: { - uri: string | undefined - onCanReply: (canReply: boolean) => void - onPressReply: () => unknown -}) { - const {hasSession} = useSession() +export function PostThread({uri}: {uri: string | undefined}) { + const {hasSession, currentAccount} = useSession() const {_} = useLingui() const t = useTheme() const {isMobile, isTabletOrMobile} = useWebMediaQueries() @@ -169,6 +166,7 @@ export function PostThread({ // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead. const [deferParents, setDeferParents] = React.useState(isNative) + const currentDid = currentAccount?.did const threadModerationCache = React.useMemo(() => { const cache: ThreadModerationCache = new WeakMap() if (thread && moderationOpts) { @@ -177,6 +175,10 @@ export function PostThread({ return cache }, [thread, moderationOpts]) + const [justPostedUris, setJustPostedUris] = React.useState( + () => new Set(), + ) + const skeleton = React.useMemo(() => { const threadViewPrefs = preferences?.threadViewPrefs if (!threadViewPrefs || !thread) return null @@ -186,21 +188,24 @@ export function PostThread({ thread, threadViewPrefs, threadModerationCache, + currentDid, + justPostedUris, threadgateRecord ?? undefined, ), - hasSession, + !!currentDid, treeView, threadModerationCache, hiddenRepliesState !== HiddenRepliesState.Hide, - threadgateRecord || undefined, + threadgateRecord ?? undefined, ) }, [ thread, preferences?.threadViewPrefs, - hasSession, + currentDid, treeView, threadModerationCache, hiddenRepliesState, + justPostedUris, threadgateRecord, ]) @@ -231,14 +236,6 @@ export function PostThread({ return null }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) - useEffect(() => { - if (error) { - onCanReply(false) - } else if (rootPost) { - onCanReply(!rootPost.viewer?.replyDisabled) - } - }, [rootPost, onCanReply, error]) - // construct content const posts = React.useMemo(() => { if (!skeleton) return [] @@ -334,6 +331,38 @@ export function PostThread({ setMaxReplies(prev => prev + 50) }, [isFetching, maxReplies, posts.length]) + const onPostReply = React.useCallback( + (postUri: string | undefined) => { + refetch() + if (postUri) { + setJustPostedUris(set => { + const nextSet = new Set(set) + nextSet.add(postUri) + return nextSet + }) + } + }, + [refetch], + ) + + const {openComposer} = useComposerControls() + const onPressReply = React.useCallback(() => { + if (thread?.type !== 'post') { + return + } + openComposer({ + replyTo: { + uri: thread.post.uri, + cid: thread.post.cid, + text: thread.record.text, + author: thread.post.author, + embed: thread.post.embed, + }, + onPost: onPostReply, + }) + }, [openComposer, thread, onPostReply]) + + const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled const hasParents = skeleton?.highlightedPost?.type === 'post' && (skeleton.highlightedPost.ctx.isParentLoading || @@ -345,7 +374,9 @@ export function PostThread({ if (item === REPLY_PROMPT && hasSession) { return ( - {!isMobile && } + {!isMobile && ( + + )} ) } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { @@ -427,7 +458,7 @@ export function PostThread({ HiddenRepliesState.ShowAndOverridePostHider && item.ctx.depth > 0 } - onPostReply={refetch} + onPostReply={onPostReply} hideTopBorder={index === 0 && !item.ctx.isParentLoading} /> @@ -494,10 +525,30 @@ export function PostThread({ sideBorders={false} /> + {isMobile && canReply && hasSession && ( + + )} ) } +function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { + const safeAreaInsets = useSafeAreaInsets() + const fabMinimalShellTransform = useMinimalShellFabTransform() + return ( + + + + ) +} + function isThreadPost(v: unknown): v is ThreadPost { return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' } @@ -516,7 +567,7 @@ function createThreadSkeleton( treeView: boolean, modCache: ThreadModerationCache, showHiddenReplies: boolean, - threadgateRecord: AppBskyFeedThreadgate.Record | undefined, + threadgateRecord?: AppBskyFeedThreadgate.Record, ): ThreadSkeletonParts | null { if (!node) return null @@ -567,7 +618,7 @@ function* flattenThreadReplies( treeView: boolean, modCache: ThreadModerationCache, showHiddenReplies: boolean, - threadgateRecord: AppBskyFeedThreadgate.Record | undefined, + threadgateRecord?: AppBskyFeedThreadgate.Record, ): Generator { if (node.type === 'post') { // dont show pwi-opted-out posts to logged out users @@ -654,3 +705,12 @@ function hasBranchingReplies(node?: ThreadNode) { } return true } + +const styles = StyleSheet.create({ + prompt: { + // @ts-ignore web-only + position: isWeb ? 'fixed' : 'absolute', + left: 0, + right: 0, + }, +}) diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx similarity index 94% rename from src/view/com/composer/Prompt.tsx rename to src/view/com/post-thread/PostThreadComposePrompt.tsx index 20637c7e9f..62b28cc759 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx @@ -10,7 +10,11 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { +export function PostThreadComposePrompt({ + onPressCompose, +}: { + onPressCompose: () => void +}) { const {currentAccount} = useSession() const {data: profile} = useProfileQuery({did: currentAccount?.did}) const pal = usePalette('default') diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 55c67befc9..0d4b15daef 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -77,7 +77,7 @@ export function PostThreadItem({ showParentReplyLine?: boolean hasPrecedingItem: boolean overrideBlur: boolean - onPostReply: () => void + onPostReply: (postUri: string | undefined) => void hideTopBorder?: boolean }) { const postShadowed = usePostShadow(post) @@ -171,7 +171,7 @@ let PostThreadItemLoaded = ({ showParentReplyLine?: boolean hasPrecedingItem: boolean overrideBlur: boolean - onPostReply: () => void + onPostReply: (postUri: string | undefined) => void hideTopBorder?: boolean }): React.ReactNode => { const pal = usePalette('default') @@ -765,6 +765,7 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', paddingBottom: 4, paddingRight: 10, + overflow: 'hidden', }, postTextLargeContainer: { paddingHorizontal: 0, diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index ade0e7f10f..8121b8abcc 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -12,17 +12,17 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {MAX_POST_LINES} from '#/lib/constants' +import {usePalette} from '#/lib/hooks/usePalette' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' +import {makeProfileLink} from '#/lib/routes/links' +import {countLines} from '#/lib/strings/helpers' +import {colors, s} from '#/lib/styles' import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {precacheProfile} from '#/state/queries/profile' import {useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' -import {MAX_POST_LINES} from 'lib/constants' -import {usePalette} from 'lib/hooks/usePalette' -import {makeProfileLink} from 'lib/routes/links' -import {countLines} from 'lib/strings/helpers' -import {colors, s} from 'lib/styles' -import {precacheProfile} from 'state/queries/profile' import {AviFollowButton} from '#/view/com/posts/AviFollowButton' import {atoms as a} from '#/alf' import {ProfileHoverCard} from '#/components/ProfileHoverCard' @@ -280,6 +280,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', + overflow: 'hidden', }, replyLine: { position: 'absolute', diff --git a/src/view/com/posts/AviFollowButton.tsx b/src/view/com/posts/AviFollowButton.tsx index f7141ee421..00428cbe61 100644 --- a/src/view/com/posts/AviFollowButton.tsx +++ b/src/view/com/posts/AviFollowButton.tsx @@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native' import {createHitslop} from '#/lib/constants' import {NavigationProp} from '#/lib/routes/types' -import {useGate} from '#/lib/statsig/statsig' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' @@ -37,7 +36,6 @@ export function AviFollowButton({ profile: profile, logContext: 'AvatarButton', }) - const gate = useGate() const {currentAccount, hasSession} = useSession() const navigation = useNavigation() @@ -80,7 +78,7 @@ export function AviFollowButton({ }, ] - return hasSession && gate('show_avi_follow_button') ? ( + return hasSession ? ( {children} diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 7623ff37e3..54ea9b1400 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -14,7 +14,6 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {logEvent, useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' @@ -181,6 +180,7 @@ let Feed = ({ ListHeaderComponent?: () => JSX.Element extraData?: any savedFeedConfig?: AppBskyActorDefs.SavedFeed + outsideHeaderOffset?: number }): React.ReactNode => { const theme = useTheme() const {track} = useAnalytics() @@ -212,8 +212,9 @@ let Feed = ({ isFetchingNextPage, fetchNextPage, } = usePostFeedQuery(feed, feedParams, opts) - if (data?.pages[0]) { - lastFetchRef.current = data?.pages[0].fetchedAt + const lastFetchedAt = data?.pages[0].fetchedAt + if (lastFetchedAt) { + lastFetchRef.current = lastFetchedAt } const isEmpty = React.useMemo( () => !isFetching && !data?.pages?.some(page => page.slices.length), @@ -348,8 +349,7 @@ let Feed = ({ const shouldShow = (interstitial.type === feedInterstitialType && gate('suggested_feeds_interstitial')) || - (interstitial.type === followInterstitialType && - gate('suggested_follows_interstitial')) || + interstitial.type === followInterstitialType || interstitial.type === progressGuideInterstitialType if (shouldShow) { @@ -358,7 +358,7 @@ let Feed = ({ ...interstitial, params: {variant}, // overwrite key with unique value - key: [interstitial.type, variant].join(':'), + key: [interstitial.type, variant, lastFetchedAt].join(':'), } if (arr.length > interstitial.slot) { @@ -374,6 +374,7 @@ let Feed = ({ isFetched, isError, isEmpty, + lastFetchedAt, data, feedUri, feedIsDiscover, @@ -472,7 +473,7 @@ let Feed = ({ } else if (item.type === progressGuideInterstitialType) { return } else if (item.type === 'slice') { - if (item.slice.rootUri === FALLBACK_MARKER_POST.post.uri) { + if (item.slice.isFallbackMarker) { // HACK // tell the user we fell back to discover // see home.ts (feed api) for more info diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 9ddc54a989..8592f0bec2 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -345,11 +345,9 @@ let FeedItemInner = ({ postHref={href} onOpenAuthor={onOpenAuthor} /> - {!isThreadChild && - showReplyTo && - (parentAuthor || isParentBlocked) && ( - - )} + {showReplyTo && (parentAuthor || isParentBlocked) && ( + + )} - {__DEV__ && gate('videos') && ( + {gate('video_debug') && ( )} { - if (slice.isThread && slice.items.length > 3) { + if (slice.isIncompleteThread && slice.items.length >= 3) { const beforeLast = slice.items.length - 2 const last = slice.items.length - 1 return ( @@ -27,25 +27,28 @@ let FeedSlice = ({ key={slice.items[0]._reactKey} post={slice.items[0].post} record={slice.items[0].record} - reason={slice.items[0].reason} - feedContext={slice.items[0].feedContext} + reason={slice.reason} + feedContext={slice.feedContext} parentAuthor={slice.items[0].parentAuthor} - showReplyTo={true} + showReplyTo={false} moderation={slice.items[0].moderation} isThreadParent={isThreadParentAt(slice.items, 0)} isThreadChild={isThreadChildAt(slice.items, 0)} hideTopBorder={hideTopBorder} isParentBlocked={slice.items[0].isParentBlocked} /> - + { - const urip = new AtUri(slice.rootUri) + const urip = new AtUri(uri) return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) - }, [slice.rootUri]) + }, [uri]) return ( diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index c7df4d75be..356b3f09cf 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -1,32 +1,60 @@ import React from 'react' -import {Pressable, ScrollView, StyleSheet, View} from 'react-native' -import {AppBskyActorDefs, moderateProfile} from '@atproto/api' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' +import {ScrollView, View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useProfileShadow} from '#/state/cache/profile-shadow' +import {logEvent} from '#/lib/statsig/statsig' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' -import {useAnalytics} from 'lib/analytics/analytics' -import {usePalette} from 'lib/hooks/usePalette' -import {makeProfileLink} from 'lib/routes/links' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' import {isWeb} from 'platform/detection' -import {Button} from 'view/com/util/forms/Button' -import {Link} from 'view/com/util/Link' -import {Text} from 'view/com/util/text/Text' -import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' -import * as Toast from '../util/Toast' +import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' -const OUTER_PADDING = 10 -const INNER_PADDING = 14 -const TOTAL_HEIGHT = 250 +const OUTER_PADDING = a.p_md.padding +const INNER_PADDING = a.p_lg.padding +const TOTAL_HEIGHT = 232 +const MOBILE_CARD_WIDTH = 300 + +function CardOuter({ + children, + style, +}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function SuggestedFollowPlaceholder() { + const t = useTheme() + return ( + + + + + + + + + ) +} export function ProfileHeaderSuggestedFollows({ actorDid, @@ -35,47 +63,55 @@ export function ProfileHeaderSuggestedFollows({ actorDid: string requestDismiss: () => void }) { - const pal = usePalette('default') - const {isLoading, data} = useSuggestedFollowsByActorQuery({ - did: actorDid, - }) + const t = useTheme() + const {_} = useLingui() + const {isLoading: isSuggestionsLoading, data} = + useSuggestedFollowsByActorQuery({ + did: actorDid, + }) + const moderationOpts = useModerationOpts() + const isLoading = isSuggestionsLoading || !moderationOpts + return ( + style={[ + t.atoms.bg_contrast_25, + { + height: '100%', + paddingTop: INNER_PADDING / 2, + }, + ]}> - - Suggested for you + style={[ + a.flex_row, + a.justify_between, + a.align_center, + a.pt_xs, + { + paddingBottom: INNER_PADDING / 2, + paddingLeft: INNER_PADDING, + paddingRight: INNER_PADDING / 2, + }, + ]}> + + Similar accounts - - - + label={_(msg`Dismiss`)} + size="xsmall" + variant="ghost" + color="secondary" + shape="round"> + + - {isLoading ? ( - <> - - - - - - - - ) : data ? ( - data.suggestions - .filter(s => (s.associated?.labeler ? false : true)) - .map(profile => ( - - )) - ) : ( - - )} + snapToInterval={MOBILE_CARD_WIDTH + a.gap_sm.gap} + decelerationRate="fast"> + + {isLoading ? ( + <> + + + + + + + ) : data ? ( + data.suggestions + .filter(s => (s.associated?.labeler ? false : true)) + .map(profile => ( + { + logEvent('profile:header:suggestedFollowsCard:press', {}) + }} + style={[a.flex_1]}> + {({hovered, pressed}) => ( + + + + + + + + + + + )} + + )) + ) : ( + + )} + ) } - -function SuggestedFollowSkeleton() { - const pal = usePalette('default') - return ( - - - - - - - ) -} - -function SuggestedFollow({ - profile: profileUnshadowed, -}: { - profile: AppBskyActorDefs.ProfileView -}) { - const {track} = useAnalytics() - const pal = usePalette('default') - const {_} = useLingui() - const moderationOpts = useModerationOpts() - const profile = useProfileShadow(profileUnshadowed) - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( - profile, - 'ProfileHeaderSuggestedFollows', - ) - - const onPressFollow = React.useCallback(async () => { - try { - track('ProfileHeader:SuggestedFollowFollowed') - await queueFollow() - } catch (e: any) { - if (e?.name !== 'AbortError') { - Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') - } - } - }, [queueFollow, track, _]) - - const onPressUnfollow = React.useCallback(async () => { - try { - await queueUnfollow() - } catch (e: any) { - if (e?.name !== 'AbortError') { - Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') - } - } - }, [queueUnfollow, _]) - - if (!moderationOpts) { - return null - } - const moderation = moderateProfile(profile, moderationOpts) - const following = profile.viewer?.following - return ( - - - - - - - {sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), - )} - - - {sanitizeHandle(profile.handle, '@')} - - - - - )} + { + if (isActive) { + setActive() + } + }}> + {active ? ( + + ) : ( + + )} + ) } diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx new file mode 100644 index 0000000000..70d887283e --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx @@ -0,0 +1,192 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import { + HLSUnsupportedError, + VideoEmbedInnerWeb, +} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' +import {ErrorBoundary} from '../ErrorBoundary' +import {useActiveVideoView} from './ActiveVideoContext' + +export function VideoEmbed({source}: {source: string}) { + const t = useTheme() + const ref = useRef(null) + const {active, setActive, sendPosition, currentActiveView} = + useActiveVideoView({source}) + const [onScreen, setOnScreen] = useState(false) + + useEffect(() => { + if (!ref.current) return + const observer = new IntersectionObserver( + entries => { + const entry = entries[0] + if (!entry) return + setOnScreen(entry.isIntersecting) + sendPosition( + entry.boundingClientRect.y + entry.boundingClientRect.height / 2, + ) + }, + {threshold: 0.5}, + ) + observer.observe(ref.current) + return () => observer.disconnect() + }, [sendPosition]) + + const [key, setKey] = useState(0) + const renderError = useCallback( + (error: unknown) => ( + setKey(key + 1)} /> + ), + [key], + ) + + return ( + +
evt.stopPropagation()}> + + + + + +
+
+ ) +} + +/** + * Renders a 100vh tall div and watches it with an IntersectionObserver to + * send the position of the div when it's near the screen. + */ +function ViewportObserver({ + children, + sendPosition, + isAnyViewActive, +}: { + children: React.ReactNode + sendPosition: (position: number) => void + isAnyViewActive?: boolean +}) { + const ref = useRef(null) + const [nearScreen, setNearScreen] = useState(false) + + // Send position when scrolling. This is done with an IntersectionObserver + // observing a div of 100vh height + useEffect(() => { + if (!ref.current) return + const observer = new IntersectionObserver( + entries => { + const entry = entries[0] + if (!entry) return + const position = + entry.boundingClientRect.y + entry.boundingClientRect.height / 2 + sendPosition(position) + setNearScreen(entry.isIntersecting) + }, + {threshold: Array.from({length: 101}, (_, i) => i / 100)}, + ) + observer.observe(ref.current) + return () => observer.disconnect() + }, [sendPosition]) + + // In case scrolling hasn't started yet, send up the position + useEffect(() => { + if (ref.current && !isAnyViewActive) { + const rect = ref.current.getBoundingClientRect() + const position = rect.y + rect.height / 2 + sendPosition(position) + } + }, [isAnyViewActive, sendPosition]) + + return ( + + {nearScreen && children} +
+ + ) +} + +function VideoError({error, retry}: {error: unknown; retry: () => void}) { + const t = useTheme() + const {_} = useLingui() + + const isHLS = error instanceof HLSUnsupportedError + + return ( + + + {isHLS ? ( + + Your browser does not support the video format. Please try a + different browser. + + ) : ( + + An error occurred while loading the video. Please try again later. + + )} + + {!isHLS && ( + + )} + + ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.tsx deleted file mode 100644 index ef06787097..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react' -import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native' -import Animated, { - measure, - runOnJS, - useAnimatedRef, - useFrameCallback, - useSharedValue, -} from 'react-native-reanimated' -import {VideoPlayer, VideoView} from 'expo-video' - -import {atoms as a} from '#/alf' -import {Text} from '#/components/Typography' -import {useVideoPlayer} from './VideoPlayerContext' - -export const VideoEmbedInner = ({}: {source: string}) => { - const player = useVideoPlayer() - const aref = useAnimatedRef() - const {height: windowHeight} = useWindowDimensions() - const hasLeftView = useSharedValue(false) - const ref = useRef(null) - - const onEnterView = useCallback(() => { - if (player.status === 'readyToPlay') { - player.play() - } - }, [player]) - - const onLeaveView = useCallback(() => { - player.pause() - }, [player]) - - const enterFullscreen = useCallback(() => { - if (ref.current) { - ref.current.enterFullscreen() - } - }, []) - - useFrameCallback(() => { - const measurement = measure(aref) - - if (measurement) { - if (hasLeftView.value) { - // Check if the video is in view - if ( - measurement.pageY >= 0 && - measurement.pageY + measurement.height <= windowHeight - ) { - runOnJS(onEnterView)() - hasLeftView.value = false - } - } else { - // Check if the video is out of view - if ( - measurement.pageY + measurement.height < 0 || - measurement.pageY > windowHeight - ) { - runOnJS(onLeaveView)() - hasLeftView.value = true - } - } - } - }) - - return ( - - - - - ) -} - -function VideoControls({ - player, - enterFullscreen, -}: { - player: VideoPlayer - enterFullscreen: () => void -}) { - const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime)) - - useEffect(() => { - const interval = setInterval(() => { - setCurrentTime(Math.floor(player.duration - player.currentTime)) - // how often should we update the time? - // 1000 gets out of sync with the video time - }, 250) - - return () => { - clearInterval(interval) - } - }, [player]) - - const minutes = Math.floor(currentTime / 60) - const seconds = String(currentTime % 60).padStart(2, '0') - - return ( - - - - {minutes}:{seconds} - - - - - ) -} - -const styles = StyleSheet.create({ - timeContainer: { - backgroundColor: 'rgba(0, 0, 0, 0.75)', - borderRadius: 6, - paddingHorizontal: 6, - paddingVertical: 3, - position: 'absolute', - left: 5, - bottom: 5, - }, - timeElapsed: { - color: 'white', - fontSize: 12, - fontWeight: 'bold', - }, -}) diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx deleted file mode 100644 index cb02743c6f..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, {useEffect, useRef} from 'react' -import Hls from 'hls.js' - -import {atoms as a} from '#/alf' - -export const VideoEmbedInner = ({source}: {source: string}) => { - const ref = useRef(null) - - // Use HLS.js to play HLS video - useEffect(() => { - if (ref.current) { - if (ref.current.canPlayType('application/vnd.apple.mpegurl')) { - ref.current.src = source - } else if (Hls.isSupported()) { - var hls = new Hls() - hls.loadSource(source) - hls.attachMedia(ref.current) - } else { - // TODO: fallback - } - } - }, [source]) - - useEffect(() => { - if (ref.current) { - const observer = new IntersectionObserver( - ([entry]) => { - if (ref.current) { - if (entry.isIntersecting) { - if (ref.current.paused) { - ref.current.play() - } - } else { - if (!ref.current.paused) { - ref.current.pause() - } - } - } - }, - {threshold: 0}, - ) - - observer.observe(ref.current) - - return () => { - observer.disconnect() - } - } - }, []) - - return