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/jest/jestSetup.js b/jest/jestSetup.js index 8fc89a3769..4c24a5b0a3 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -116,4 +116,7 @@ jest.mock('expo-modules-core', () => ({ } } }), + requireNativeViewManager: jest.fn().mockImplementation(moduleName => { + return () => null + }), })) 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 adb535e7f9..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,18 @@ { "platforms": ["ios", "tvos", "android", "web"], "ios": { - "modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoPlatformInfoModule"] + "modules": [ + "ExpoBlueskySharedPrefsModule", + "ExpoBlueskyReferrerModule", + "ExpoBlueskyVisibilityViewModule", + "ExpoPlatformInfoModule" + ] }, "android": { "modules": [ "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule", "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule", + "expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule", "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule" ] } diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts index f62596cb70..2cf4f36c52 100644 --- a/modules/expo-bluesky-swiss-army/index.ts +++ b/modules/expo-bluesky-swiss-army/index.ts @@ -1,5 +1,7 @@ import * as PlatformInfo from './src/PlatformInfo' +import {AudioCategory} from './src/PlatformInfo/types' import * as Referrer from './src/Referrer' import * as SharedPrefs from './src/SharedPrefs' +import VisibilityView from './src/VisibilityView' -export {PlatformInfo, Referrer, SharedPrefs} +export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView} diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift index 4a1e6d7e7d..7fd60e5fa2 100644 --- a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift +++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift @@ -7,5 +7,36 @@ public class ExpoPlatformInfoModule: Module { Function("getIsReducedMotionEnabled") { return UIAccessibility.isReduceMotionEnabled } + + Function("setAudioCategory") { (audioCategoryString: String) in + let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString) + try? AVAudioSession.sharedInstance().setCategory(audioCategory) + } + + Function("setAudioActive") { (active: Bool) in + var categoryOptions: AVAudioSession.CategoryOptions + let currentCategory = AVAudioSession.sharedInstance().category + + if active { + categoryOptions = [.mixWithOthers] + try? AVAudioSession.sharedInstance().setActive(true) + } else { + categoryOptions = [.duckOthers] + try? AVAudioSession + .sharedInstance() + .setActive( + false, + options: [.notifyOthersOnDeactivation] + ) + } + + try? AVAudioSession + .sharedInstance() + .setCategory( + currentCategory, + mode: .default, + options: categoryOptions + ) + } } } 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 index e05f173d64..b515206d9f 100644 --- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts @@ -1,7 +1,20 @@ +import {Platform} from 'react-native' import {requireNativeModule} from 'expo-modules-core' +import {AudioCategory} from './types' + const NativeModule = requireNativeModule('ExpoPlatformInfo') export function getIsReducedMotionEnabled(): boolean { return NativeModule.getIsReducedMotionEnabled() } + +export function setAudioActive(active: boolean): void { + if (Platform.OS !== 'ios') return + NativeModule.setAudioActive(active) +} + +export function setAudioCategory(audioCategory: AudioCategory): void { + if (Platform.OS !== 'ios') return + NativeModule.setAudioCategory(audioCategory) +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts index 9b9b7fc0c7..81f8c45f4d 100644 --- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts @@ -1,5 +1,25 @@ import {NotImplementedError} from '../NotImplemented' +import {AudioCategory} from './types' export function getIsReducedMotionEnabled(): boolean { throw new NotImplementedError() } + +/** + * Set whether the app's audio should mix with other apps' audio. Will also resume background music playback when `false` + * if it was previously playing. + * @param mixWithOthers + * @see https://developer.apple.com/documentation/avfaudio/avaudiosession/setactiveoptions/1616603-notifyothersondeactivation + */ +export function setAudioActive(active: boolean): void { + throw new NotImplementedError({active}) +} + +/** + * Set the audio category for the app. + * @param audioCategory + * @platform ios + */ +export function setAudioCategory(audioCategory: AudioCategory): void { + throw new NotImplementedError({audioCategory}) +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts index c7ae6b7cd4..61412753c9 100644 --- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts @@ -1,6 +1,17 @@ +import {NotImplementedError} from '../NotImplemented' +import {AudioCategory} from './types' + export function getIsReducedMotionEnabled(): boolean { if (typeof window === 'undefined') { return false } return window.matchMedia('(prefers-reduced-motion: reduce)').matches } + +export function setAudioActive(active: boolean): void { + throw new NotImplementedError({active}) +} + +export function setAudioCategory(audioCategory: AudioCategory): void { + throw new NotImplementedError({audioCategory}) +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts new file mode 100644 index 0000000000..374f343183 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts @@ -0,0 +1,15 @@ +/** + * Sets the audio session category on iOS. In general, we should only need to use this for the `playback` and `ambient` + * categories. This enum however includes other categories that are available in the native API for clarity and + * potential future use. + * @see https://developer.apple.com/documentation/avfoundation/avaudiosession/category + * @platform ios + */ +export enum AudioCategory { + Ambient = 'AVAudioSessionCategoryAmbient', + Playback = 'AVAudioSessionCategoryPlayback', + _SoloAmbient = 'AVAudioSessionCategorySoloAmbient', + _Record = 'AVAudioSessionCategoryRecord', + _PlayAndRecord = 'AVAudioSessionCategoryPlayAndRecord', + _MultiRoute = 'AVAudioSessionCategoryMultiRoute', +} 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 a2c6cccb94..3d19e12027 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.10", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", @@ -137,7 +137,7 @@ "expo-system-ui": "~3.0.4", "expo-task-manager": "~11.8.2", "expo-updates": "~0.25.21", - "expo-video": "^1.1.10", + "expo-video": "^1.2.4", "expo-web-browser": "~13.0.3", "fast-text-encoding": "^1.0.6", "history": "^5.3.0", diff --git a/patches/expo-video+1.1.10.patch b/patches/expo-video+1.1.10.patch deleted file mode 100644 index b183be9d41..0000000000 --- a/patches/expo-video+1.1.10.patch +++ /dev/null @@ -1,20 +0,0 @@ ---- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt -+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt -@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole - setShowPreviousButton(!requireLinearPlayback) - setShowNextButton(!requireLinearPlayback) - setTimeBarInteractive(requireLinearPlayback) -+ setShowSubtitleButton(true) - } - - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) -@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) { - - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) - internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) { -- val fullscreenButton = findViewById(androidx.media3.ui.R.id.exo_fullscreen) -+ val fullscreenButton = -+ findViewById(androidx.media3.ui.R.id.exo_fullscreen) - fullscreenButton?.visibility = if (visible) { - android.view.View.VISIBLE - } else { diff --git a/patches/expo-video+1.2.4.patch b/patches/expo-video+1.2.4.patch new file mode 100644 index 0000000000..918c8a8d25 --- /dev/null +++ b/patches/expo-video+1.2.4.patch @@ -0,0 +1,135 @@ +diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt +index 9905e13..47342ff 100644 +--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt ++++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt +@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole + setShowPreviousButton(!requireLinearPlayback) + setShowNextButton(!requireLinearPlayback) + setTimeBarInteractive(requireLinearPlayback) ++ setShowSubtitleButton(true) + } + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) { + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) { +- val fullscreenButton = findViewById(androidx.media3.ui.R.id.exo_fullscreen) ++ val fullscreenButton = ++ findViewById(androidx.media3.ui.R.id.exo_fullscreen) + fullscreenButton?.visibility = if (visible) { + android.view.View.VISIBLE + } else { +diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt +index ec3da2a..5a1397a 100644 +--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt ++++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt +@@ -43,7 +43,9 @@ class VideoModule : Module() { + View(VideoView::class) { + Events( + "onPictureInPictureStart", +- "onPictureInPictureStop" ++ "onPictureInPictureStop", ++ "onEnterFullscreen", ++ "onExitFullscreen" + ) + + Prop("player") { view: VideoView, player: VideoPlayer -> +diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt +index a951d80..3932535 100644 +--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt ++++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt +@@ -36,6 +36,8 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap + val playerView: PlayerView = PlayerView(context.applicationContext) + val onPictureInPictureStart by EventDispatcher() + val onPictureInPictureStop by EventDispatcher() ++ val onEnterFullscreen by EventDispatcher() ++ val onExitFullscreen by EventDispatcher() + + var willEnterPiP: Boolean = false + var isInFullscreen: Boolean = false +@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap + @Suppress("DEPRECATION") + currentActivity.overridePendingTransition(0, 0) + } ++ onEnterFullscreen(mapOf()) + isInFullscreen = true + } + +@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap + val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen) + fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter) + videoPlayer?.changePlayerView(playerView) ++ this.onExitFullscreen(mapOf()) + isInFullscreen = false + } + +diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts +index cb9ca6d..60e9f4e 100644 +--- a/node_modules/expo-video/build/VideoView.types.d.ts ++++ b/node_modules/expo-video/build/VideoView.types.d.ts +@@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps { + * @platform ios 16.0+ + */ + allowsVideoFrameAnalysis?: boolean; ++ ++ onEnterFullscreen?: () => void; ++ onExitFullscreen?: () => void; + } + //# sourceMappingURL=VideoView.types.d.ts.map +diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift +index c537a12..e4a918f 100644 +--- a/node_modules/expo-video/ios/VideoModule.swift ++++ b/node_modules/expo-video/ios/VideoModule.swift +@@ -16,7 +16,9 @@ public final class VideoModule: Module { + View(VideoView.self) { + Events( + "onPictureInPictureStart", +- "onPictureInPictureStop" ++ "onPictureInPictureStop", ++ "onEnterFullscreen", ++ "onExitFullscreen" + ) + + Prop("player") { (view, player: VideoPlayer?) in +diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift +index f4579e4..10c5908 100644 +--- a/node_modules/expo-video/ios/VideoView.swift ++++ b/node_modules/expo-video/ios/VideoView.swift +@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate { + + let onPictureInPictureStart = EventDispatcher() + let onPictureInPictureStop = EventDispatcher() ++ let onEnterFullscreen = EventDispatcher() ++ let onExitFullscreen = EventDispatcher() + + public override var bounds: CGRect { + didSet { +@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate { + _ playerViewController: AVPlayerViewController, + willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator + ) { ++ onEnterFullscreen() + isFullscreen = true + } + +@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate { + if wasPlaying { + self.player?.pointer.play() + } ++ self.onExitFullscreen() + self.isFullscreen = false + } + } +diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts +index 29fe5db..e1fbf59 100644 +--- a/node_modules/expo-video/src/VideoView.types.ts ++++ b/node_modules/expo-video/src/VideoView.types.ts +@@ -100,4 +100,7 @@ export interface VideoViewProps extends ViewProps { + * @platform ios 16.0+ + */ + allowsVideoFrameAnalysis?: boolean; ++ ++ onEnterFullscreen?: () => void; ++ onExitFullscreen?: () => void; + } diff --git a/patches/expo-video+1.2.4.patch.md b/patches/expo-video+1.2.4.patch.md new file mode 100644 index 0000000000..689cf9a926 --- /dev/null +++ b/patches/expo-video+1.2.4.patch.md @@ -0,0 +1,6 @@ +## uwu woad beawing, do not wemove + +## `expo-video` Patch + +This patch adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on +the tin. diff --git a/patches/react-native-reanimated+3.11.0.patch b/patches/react-native-reanimated+3.11.0.patch new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/App.native.tsx b/src/App.native.tsx index d2c20fc8e7..8e7c53b93b 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -61,6 +61,7 @@ import {Provider as PortalProvider} from '#/components/Portal' import {Splash} from '#/Splash' import {Provider as TourProvider} from '#/tours' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' +import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army' SplashScreen.preventAutoHideAsync() @@ -157,6 +158,8 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { + PlatformInfo.setAudioCategory(AudioCategory.Ambient) + PlatformInfo.setAudioActive(true) initPersistedState().then(() => setReady(true)) }, []) diff --git a/src/alf/themes.ts b/src/alf/themes.ts index ba18ee0072..f5d2247f9f 100644 --- a/src/alf/themes.ts +++ b/src/alf/themes.ts @@ -186,19 +186,19 @@ export function createThemes({ white: color.gray_0, black: color.trueBlack, - contrast_25: color.gray_1000, - contrast_50: color.gray_975, - contrast_100: color.gray_950, - contrast_200: color.gray_900, - contrast_300: color.gray_800, - contrast_400: color.gray_700, - contrast_500: color.gray_600, - contrast_600: color.gray_500, - contrast_700: color.gray_400, - contrast_800: color.gray_300, - contrast_900: color.gray_200, - contrast_950: color.gray_100, - contrast_975: color.gray_50, + contrast_25: color.gray_975, + contrast_50: color.gray_950, + contrast_100: color.gray_900, + contrast_200: color.gray_800, + contrast_300: color.gray_700, + contrast_400: color.gray_600, + contrast_500: color.gray_500, + contrast_600: color.gray_400, + contrast_700: color.gray_300, + contrast_800: color.gray_200, + contrast_900: color.gray_100, + contrast_950: color.gray_50, + contrast_975: color.gray_25, primary_25: color.primary_975, primary_50: color.primary_950, @@ -400,7 +400,7 @@ export function createThemes({ color: darkPalette.contrast_400, }, text_contrast_medium: { - color: darkPalette.contrast_700, + color: darkPalette.contrast_600, }, text_contrast_high: { color: darkPalette.contrast_900, diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 4fe0ab4b12..7881fc9b5e 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -202,28 +202,10 @@ export const Button = React.forwardRef( } else if (color === 'secondary') { if (variant === 'solid') { if (!disabled) { - baseStyles.push({ - backgroundColor: select(t.name, { - light: t.palette.contrast_25, - dim: t.palette.contrast_100, - dark: t.palette.contrast_100, - }), - }) - hoverStyles.push({ - backgroundColor: select(t.name, { - light: t.palette.contrast_50, - dim: t.palette.contrast_200, - dark: t.palette.contrast_200, - }), - }) + baseStyles.push(t.atoms.bg_contrast_25) + hoverStyles.push(t.atoms.bg_contrast_50) } else { - baseStyles.push({ - backgroundColor: select(t.name, { - light: t.palette.contrast_100, - dim: t.palette.contrast_25, - dark: t.palette.contrast_25, - }), - }) + baseStyles.push(t.atoms.bg_contrast_100) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { 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/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index a64edcd6f0..4c60c6ebeb 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -249,7 +249,9 @@ function DialogError({details}: {details?: string}) { const control = Dialog.useDialogContext() 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 be9014029c..01ffbe5cf6 100644 --- a/src/lib/moderation/useModerationCauseDescription.ts +++ b/src/lib/moderation/useModerationCauseDescription.ts @@ -126,7 +126,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/lib/styles.ts b/src/lib/styles.ts index 263127440f..d0ea4cdc17 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -1,6 +1,7 @@ import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native' -import {Theme, TypographyVariant} from './ThemeContext' + import {isWeb} from 'platform/detection' +import {Theme, TypographyVariant} from './ThemeContext' // 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest export const colors = { diff --git a/src/lib/themes.ts b/src/lib/themes.ts index f3382fc438..9590f16596 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -295,9 +295,9 @@ export const darkTheme: Theme = { ...defaultTheme.palette, default: { background: darkPalette.black, - backgroundLight: darkPalette.contrast_50, + backgroundLight: darkPalette.contrast_25, text: darkPalette.white, - textLight: darkPalette.contrast_700, + textLight: darkPalette.contrast_600, textInverted: darkPalette.black, link: darkPalette.primary_500, border: darkPalette.contrast_100, @@ -344,7 +344,7 @@ export const dimTheme: Theme = { default: { ...darkTheme.palette.default, background: dimPalette.black, - backgroundLight: dimPalette.contrast_50, + backgroundLight: dimPalette.contrast_25, text: dimPalette.white, textLight: dimPalette.contrast_700, textInverted: dimPalette.black, diff --git a/src/platform/detection.ts b/src/platform/detection.ts index f00df0ee4e..c62ae71aae 100644 --- a/src/platform/detection.ts +++ b/src/platform/detection.ts @@ -14,6 +14,7 @@ export const isMobileWeb = isWeb && // @ts-ignore we know window exists -prf global.window.matchMedia(isMobileWebMediaQuery)?.matches +export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent) export const deviceLocales = dedupArray( getLocales?.() 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) && ( ) { ) 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 399a7e7932..331a111a2e 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -1,5 +1,6 @@ import {z} from 'zod' +import {logger} from '#/logger' import {deviceLocales} from '#/platform/detection' import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army' @@ -43,7 +44,7 @@ const currentAccountSchema = accountSchema.extend({ }) export type PersistedCurrentAccount = z.infer -export const schema = z.object({ +const schema = z.object({ colorMode: z.enum(['system', 'light', 'dark']), darkTheme: z.enum(['dim', 'dark']).optional(), session: z.object({ @@ -90,6 +91,7 @@ export const schema = z.object({ disableAutoplay: z.boolean().optional(), kawaii: z.boolean().optional(), hasCheckedForStarterPack: z.boolean().optional(), + subtitlesEnabled: z.boolean().optional(), /** @deprecated */ mutedThreads: z.array(z.string()), }) @@ -132,4 +134,45 @@ export const defaults: Schema = { disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(), kawaii: false, hasCheckedForStarterPack: false, + subtitlesEnabled: true, +} + +export function tryParse(rawData: string): Schema | undefined { + let objData + try { + objData = JSON.parse(rawData) + } catch (e) { + logger.error('persisted state: failed to parse root state from storage', { + message: e, + }) + } + if (!objData) { + return undefined + } + const parsed = schema.safeParse(objData) + if (parsed.success) { + return objData + } else { + const errors = + parsed.error?.errors?.map(e => ({ + code: e.code, + // @ts-ignore exists on some types + expected: e?.expected, + path: e.path?.join('.'), + })) || [] + logger.error(`persisted store: data failed validation on read`, {errors}) + return undefined + } +} + +export function tryStringify(value: Schema): string | undefined { + try { + schema.parse(value) + return JSON.stringify(value) + } catch (e) { + logger.error(`persisted state: failed stringifying root state`, { + message: e, + }) + return undefined + } } 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 db85e8a177..fd419d1c44 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -136,6 +136,8 @@ export function sortThread( node: ThreadNode, opts: UsePreferencesQueryResponse['threadViewPrefs'], modCache: ThreadModerationCache, + currentDid: string | undefined, + justPostedUris: Set, ): ThreadNode { if (node.type !== 'post') { return node @@ -149,6 +151,20 @@ export function sortThread( return -1 } + if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) { + const aIsJustPosted = + a.post.author.did === currentDid && justPostedUris.has(a.post.uri) + const bIsJustPosted = + b.post.author.did === currentDid && justPostedUris.has(b.post.uri) + if (aIsJustPosted && bIsJustPosted) { + return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest + } else if (aIsJustPosted) { + return -1 // reply while onscreen + } else if (bIsJustPosted) { + return 1 // reply while onscreen + } + } + const aIsByOp = a.post.author.did === node.post?.author.did const bIsByOp = b.post.author.did === node.post?.author.did if (aIsByOp && bIsByOp) { @@ -159,6 +175,16 @@ export function sortThread( return 1 // op's own reply } + const aIsBySelf = a.post.author.did === currentDid + const bIsBySelf = b.post.author.did === currentDid + if (aIsBySelf && bIsBySelf) { + return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest + } else if (aIsBySelf) { + return -1 // current account's reply + } else if (bIsBySelf) { + return 1 // current account's reply + } + const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur) const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur) if (aBlur !== bBlur) { @@ -195,7 +221,9 @@ export function sortThread( } return b.post.indexedAt.localeCompare(a.post.indexedAt) }) - node.replies.forEach(reply => sortThread(reply, opts, modCache)) + node.replies.forEach(reply => + sortThread(reply, opts, modCache, currentDid, justPostedUris), + ) } return node } 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 08ce4441f0..dba37d82bf 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/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx index db58448a30..a44b633cd5 100644 --- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx +++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native' // @ts-expect-error no type definition import ProgressPie from 'react-native-progress/Pie' import {ImagePickerAsset} from 'expo-image-picker' +import {Trans} from '@lingui/macro' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' @@ -46,7 +47,9 @@ export function VideoTranscodeProgress({ color={t.atoms.text.color} progress={progress} /> - Compressing... + + Compressing... + ) 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() @@ -154,6 +151,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) { @@ -162,13 +160,23 @@ 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 return createThreadSkeleton( - sortThread(thread, threadViewPrefs, threadModerationCache), - hasSession, + sortThread( + thread, + threadViewPrefs, + threadModerationCache, + currentDid, + justPostedUris, + ), + !!currentDid, treeView, threadModerationCache, hiddenRepliesState !== HiddenRepliesState.Hide, @@ -176,10 +184,11 @@ export function PostThread({ }, [ thread, preferences?.threadViewPrefs, - hasSession, + currentDid, treeView, threadModerationCache, hiddenRepliesState, + justPostedUris, ]) const error = React.useMemo(() => { @@ -209,14 +218,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 [] @@ -312,6 +313,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 || @@ -323,7 +356,9 @@ export function PostThread({ if (item === REPLY_PROMPT && hasSession) { return ( - {!isMobile && } + {!isMobile && ( + + )} ) } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { @@ -405,7 +440,7 @@ export function PostThread({ HiddenRepliesState.ShowAndOverridePostHider && item.ctx.depth > 0 } - onPostReply={refetch} + onPostReply={onPostReply} hideTopBorder={index === 0 && !item.ctx.isParentLoading} /> @@ -472,10 +507,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' } @@ -621,3 +676,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 63dac300a8..0ff3601434 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -75,7 +75,7 @@ export function PostThreadItem({ showParentReplyLine?: boolean hasPrecedingItem: boolean overrideBlur: boolean - onPostReply: () => void + onPostReply: (postUri: string | undefined) => void hideTopBorder?: boolean }) { const postShadowed = usePostShadow(post) @@ -169,7 +169,7 @@ let PostThreadItemLoaded = ({ showParentReplyLine?: boolean hasPrecedingItem: boolean overrideBlur: boolean - onPostReply: () => void + onPostReply: (postUri: string | undefined) => void hideTopBorder?: boolean }): React.ReactNode => { const pal = usePalette('default') @@ -660,7 +660,7 @@ function ExpandedPostDetails({ a.flex_row, a.align_center, a.flex_wrap, - a.gap_sm, + a.gap_xs, s.mt2, s.mb10, ]}> @@ -742,6 +742,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 a45302aa8d..deeaea283a 100644 --- a/src/view/com/posts/AviFollowButton.tsx +++ b/src/view/com/posts/AviFollowButton.tsx @@ -5,7 +5,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {createHitslop} from '#/lib/constants' -import {useGate} from '#/lib/statsig/statsig' +// import {NavigationProp} from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' @@ -29,7 +29,6 @@ export function AviFollowButton({ // profile: profile, // logContext: 'AvatarButton', // }) - const gate = useGate() const {currentAccount, hasSession} = useSession() // const navigation = useNavigation() @@ -72,7 +71,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..a179484000 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,16 +473,14 @@ 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 // -prf return } - return ( - - ) + return } else { return null } diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 9ddc54a989..0071e2401b 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -1,4 +1,4 @@ -import React, {memo, useMemo, useState} from 'react' +import React, {memo, useId, useMemo, useState} from 'react' import {StyleSheet, View} from 'react-native' import { AppBskyActorDefs, @@ -137,7 +137,6 @@ let FeedItemInner = ({ const {openComposer} = useComposerControls() const pal = usePalette('default') const {_} = useLingui() - const gate = useGate() const href = useMemo(() => { const urip = new AtUri(post.uri) @@ -345,11 +344,9 @@ let FeedItemInner = ({ postHref={href} onOpenAuthor={onOpenAuthor} /> - {!isThreadChild && - showReplyTo && - (parentAuthor || isParentBlocked) && ( - - )} + {showReplyTo && (parentAuthor || isParentBlocked) && ( + + )} - {__DEV__ && gate('videos') && ( - - )} + + ) +} + const styles = StyleSheet.create({ outer: { paddingLeft: 10, paddingRight: 15, // @ts-ignore web only -prf cursor: 'pointer', - overflow: 'hidden', }, replyLine: { width: 2, @@ -547,6 +554,7 @@ const styles = StyleSheet.create({ alignItems: 'center', flexWrap: 'wrap', paddingBottom: 2, + overflow: 'hidden', }, contentHiderChild: { marginTop: 6, diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 8d707d78ea..fcd1ec3b18 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -18,7 +18,7 @@ let FeedSlice = ({ slice: FeedPostSlice hideTopBorder?: boolean }): React.ReactNode => { - 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 ? ( + + ) : ( + + )} + + ) } + +function VideoError({retry}: {error: unknown; retry: () => void}) { + return ( + + + + An error occurred while loading the video. Please try again later. + + + + + ) +} 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..5803b836df --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx @@ -0,0 +1,158 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import { + HLSUnsupportedError, + VideoEmbedInnerWeb, +} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' +import {atoms as a, useTheme} from '#/alf' +import {ErrorBoundary} from '../ErrorBoundary' +import {useActiveVideoView} from './ActiveVideoContext' +import * as VideoFallback from './VideoEmbedInner/VideoFallback' + +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 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