From 6298e6897fa8f4a0d296869777326cd43fb875a0 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sat, 3 Aug 2024 00:33:45 +0200 Subject: [PATCH 01/30] tweak list header (#4870) Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- src/components/Lists.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index e706e101f5..beeb554763 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -122,8 +122,16 @@ export function ListHeaderDesktop({ if (!gtTablet) return null return ( - - {title} + + {title} {subtitle ? ( {subtitle} From fb278384c64f55e5037275a23f4bd7af91dc7274 Mon Sep 17 00:00:00 2001 From: bnewbold Date: Fri, 2 Aug 2024 15:57:50 -0700 Subject: [PATCH 02/30] bskyweb: optional basic auth password middleware (#4759) --- bskyweb/cmd/bskyweb/main.go | 13 ++++++++++--- bskyweb/cmd/bskyweb/server.go | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go index 908486aa7e..d9235afdee 100644 --- a/bskyweb/cmd/bskyweb/main.go +++ b/bskyweb/cmd/bskyweb/main.go @@ -41,10 +41,10 @@ func run(args []string) { EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"}, }, &cli.StringFlag{ - Name: "ogcard-host", - Usage: "scheme, hostname, and port of ogcard service", + Name: "ogcard-host", + Usage: "scheme, hostname, and port of ogcard service", Required: false, - EnvVars: []string{"OGCARD_HOST"}, + EnvVars: []string{"OGCARD_HOST"}, }, &cli.StringFlag{ Name: "http-address", @@ -67,6 +67,13 @@ func run(args []string) { Required: false, EnvVars: []string{"DEBUG"}, }, + &cli.StringFlag{ + Name: "basic-auth-password", + Usage: "optional password to restrict access to web interface", + Required: false, + Value: "", + EnvVars: []string{"BASIC_AUTH_PASSWORD"}, + }, }, }, } diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 8da291fe56..fdef01ce78 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/subtle" "errors" "fmt" "io/fs" @@ -48,6 +49,7 @@ func serve(cctx *cli.Context) error { appviewHost := cctx.String("appview-host") ogcardHost := cctx.String("ogcard-host") linkHost := cctx.String("link-host") + basicAuthPassword := cctx.String("basic-auth-password") // Echo e := echo.New() @@ -140,6 +142,18 @@ func serve(cctx *cli.Context) error { }, })) + // optional password gating of entire web interface + if basicAuthPassword != "" { + e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) { + // Be careful to use constant time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(username), []byte("admin")) == 1 && + subtle.ConstantTimeCompare([]byte(password), []byte(basicAuthPassword)) == 1 { + return true, nil + } + return false, nil + })) + } + // redirect trailing slash to non-trailing slash. // all of our current endpoints have no trailing slash. e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{ From 18b423396b75d8b4348a434412d0da1f38230717 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 5 Aug 2024 12:21:34 -0700 Subject: [PATCH 03/30] Add `PlatformInfo` module (#4877) --- jest/jestSetup.js | 10 +++++++ .../platforminfo/ExpoPlatformInfoModule.kt | 24 ++++++++++++++++ .../expo-module.config.json | 5 ++-- modules/expo-bluesky-swiss-army/index.ts | 3 +- .../PlatformInfo/ExpoPlatformInfoModule.swift | 11 ++++++++ .../src/PlatformInfo/index.native.ts | 7 +++++ .../src/PlatformInfo/index.ts | 5 ++++ .../src/PlatformInfo/index.web.ts | 6 ++++ patches/react-native-reanimated+3.11.0.patch | 28 ------------------- src/platform/detection.ts | 3 -- src/state/a11y.tsx | 4 +-- src/state/persisted/schema.ts | 5 ++-- src/view/screens/Storybook/Dialogs.tsx | 19 +++++++++++++ 13 files changed, 92 insertions(+), 38 deletions(-) create mode 100644 modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt create mode 100644 modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift create mode 100644 modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts create mode 100644 modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts create mode 100644 modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts diff --git a/jest/jestSetup.js b/jest/jestSetup.js index a6b7c24f69..ac175900ed 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -95,3 +95,13 @@ jest.mock('expo-application', () => ({ nativeApplicationVersion: '1.0.0', nativeBuildVersion: '1', })) + +jest.mock('expo-modules-core', () => ({ + requireNativeModule: jest.fn().mockImplementation(moduleName => { + if (moduleName === 'ExpoPlatformInfo') { + return { + getIsReducedMotionEnabled: () => false, + } + } + }), +})) diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt new file mode 100644 index 0000000000..189796f817 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt @@ -0,0 +1,24 @@ +package expo.modules.blueskyswissarmy.platforminfo + +import android.provider.Settings +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoPlatformInfoModule : Module() { + override fun definition() = + ModuleDefinition { + Name("ExpoPlatformInfo") + + // See https://github.com/software-mansion/react-native-reanimated/blob/7df5fd57d608fe25724608835461cd925ff5151d/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeProxy/NativeProxyCommon.java#L242 + Function("getIsReducedMotionEnabled") { + val resolver = appContext.reactContext?.contentResolver ?: return@Function false + val scale = Settings.Global.getString(resolver, Settings.Global.TRANSITION_ANIMATION_SCALE) ?: return@Function false + + try { + return@Function scale.toFloat() == 0f + } catch (_: Error) { + return@Function false + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json index 1111f8a0be..adb535e7f9 100644 --- a/modules/expo-bluesky-swiss-army/expo-module.config.json +++ b/modules/expo-bluesky-swiss-army/expo-module.config.json @@ -1,12 +1,13 @@ { "platforms": ["ios", "tvos", "android", "web"], "ios": { - "modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"] + "modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoPlatformInfoModule"] }, "android": { "modules": [ "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule", - "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule" + "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule", + "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule" ] } } diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts index 89cea00a28..f62596cb70 100644 --- a/modules/expo-bluesky-swiss-army/index.ts +++ b/modules/expo-bluesky-swiss-army/index.ts @@ -1,4 +1,5 @@ +import * as PlatformInfo from './src/PlatformInfo' import * as Referrer from './src/Referrer' import * as SharedPrefs from './src/SharedPrefs' -export {Referrer, SharedPrefs} +export {PlatformInfo, Referrer, SharedPrefs} diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift new file mode 100644 index 0000000000..4a1e6d7e7d --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift @@ -0,0 +1,11 @@ +import ExpoModulesCore + +public class ExpoPlatformInfoModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoPlatformInfo") + + Function("getIsReducedMotionEnabled") { + return UIAccessibility.isReduceMotionEnabled + } + } +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts new file mode 100644 index 0000000000..e05f173d64 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts @@ -0,0 +1,7 @@ +import {requireNativeModule} from 'expo-modules-core' + +const NativeModule = requireNativeModule('ExpoPlatformInfo') + +export function getIsReducedMotionEnabled(): boolean { + return NativeModule.getIsReducedMotionEnabled() +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts new file mode 100644 index 0000000000..9b9b7fc0c7 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts @@ -0,0 +1,5 @@ +import {NotImplementedError} from '../NotImplemented' + +export function getIsReducedMotionEnabled(): boolean { + throw new NotImplementedError() +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts new file mode 100644 index 0000000000..c7ae6b7cd4 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts @@ -0,0 +1,6 @@ +export function getIsReducedMotionEnabled(): boolean { + if (typeof window === 'undefined') { + return false + } + return window.matchMedia('(prefers-reduced-motion: reduce)').matches +} diff --git a/patches/react-native-reanimated+3.11.0.patch b/patches/react-native-reanimated+3.11.0.patch index 9147cf08ef..a79a0ac085 100644 --- a/patches/react-native-reanimated+3.11.0.patch +++ b/patches/react-native-reanimated+3.11.0.patch @@ -207,31 +207,3 @@ index 88b3fdf..2488ebc 100644 const { layout, entering, exiting, sharedTransitionTag } = this.props; if ( -diff --git a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js -index ac9be5d..86d4605 100644 ---- a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js -+++ b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js -@@ -47,4 +47,5 @@ export { LayoutAnimationConfig } from './component/LayoutAnimationConfig'; - export { PerformanceMonitor } from './component/PerformanceMonitor'; - export { startMapper, stopMapper } from './mappers'; - export { startScreenTransition, finishScreenTransition, ScreenTransition } from './screenTransition'; -+export { isReducedMotion } from './PlatformChecker'; - //# sourceMappingURL=index.js.map -diff --git a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts -index f01dc57..161ef22 100644 ---- a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts -+++ b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts -@@ -36,3 +36,4 @@ export type { FlatListPropsWithLayout } from './component/FlatList'; - export { startMapper, stopMapper } from './mappers'; - export { startScreenTransition, finishScreenTransition, ScreenTransition, } from './screenTransition'; - export type { AnimatedScreenTransition, GoBackGesture, ScreenTransitionConfig, } from './screenTransition'; -+export { isReducedMotion } from './PlatformChecker'; -diff --git a/node_modules/react-native-reanimated/src/reanimated2/index.ts b/node_modules/react-native-reanimated/src/reanimated2/index.ts -index 5885fa1..a3c693f 100644 ---- a/node_modules/react-native-reanimated/src/reanimated2/index.ts -+++ b/node_modules/react-native-reanimated/src/reanimated2/index.ts -@@ -284,3 +284,4 @@ export type { - GoBackGesture, - ScreenTransitionConfig, - } from './screenTransition'; -+export { isReducedMotion } from './PlatformChecker'; diff --git a/src/platform/detection.ts b/src/platform/detection.ts index 0c0360a82a..f00df0ee4e 100644 --- a/src/platform/detection.ts +++ b/src/platform/detection.ts @@ -1,5 +1,4 @@ import {Platform} from 'react-native' -import {isReducedMotion} from 'react-native-reanimated' import {getLocales} from 'expo-localization' import {fixLegacyLanguageCode} from '#/locale/helpers' @@ -21,5 +20,3 @@ export const deviceLocales = dedupArray( .map?.(locale => fixLegacyLanguageCode(locale.languageCode)) .filter(code => typeof code === 'string'), ) as string[] - -export const prefersReducedMotion = isReducedMotion() diff --git a/src/state/a11y.tsx b/src/state/a11y.tsx index aefcfd1ec4..08948267c0 100644 --- a/src/state/a11y.tsx +++ b/src/state/a11y.tsx @@ -1,8 +1,8 @@ import React from 'react' import {AccessibilityInfo} from 'react-native' -import {isReducedMotion} from 'react-native-reanimated' import {isWeb} from '#/platform/detection' +import {PlatformInfo} from '../../modules/expo-bluesky-swiss-army' const Context = React.createContext({ reduceMotionEnabled: false, @@ -15,7 +15,7 @@ export function useA11y() { export function Provider({children}: React.PropsWithChildren<{}>) { const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() => - isReducedMotion(), + PlatformInfo.getIsReducedMotionEnabled(), ) const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false) diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 88fc370a6f..399a7e7932 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -1,6 +1,7 @@ import {z} from 'zod' -import {deviceLocales, prefersReducedMotion} from '#/platform/detection' +import {deviceLocales} from '#/platform/detection' +import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army' const externalEmbedOptions = ['show', 'hide'] as const @@ -128,7 +129,7 @@ export const defaults: Schema = { lastSelectedHomeFeed: undefined, pdsAddressHistory: [], disableHaptics: false, - disableAutoplay: prefersReducedMotion, + disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(), kawaii: false, hasCheckedForStarterPack: false, } diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx index ca2420fed0..3a9f67de81 100644 --- a/src/view/screens/Storybook/Dialogs.tsx +++ b/src/view/screens/Storybook/Dialogs.tsx @@ -9,6 +9,7 @@ import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import * as Prompt from '#/components/Prompt' import {H3, P, Text} from '#/components/Typography' +import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' export function Dialogs() { const scrollable = Dialog.useDialogControl() @@ -17,6 +18,8 @@ export function Dialogs() { const testDialog = Dialog.useDialogControl() const {closeAllDialogs} = useDialogStateControlContext() const unmountTestDialog = Dialog.useDialogControl() + const [reducedMotionEnabled, setReducedMotionEnabled] = + React.useState() const [shouldRenderUnmountTest, setShouldRenderUnmountTest] = React.useState(false) const unmountTestInterval = React.useRef() @@ -147,6 +150,22 @@ export function Dialogs() { Open Shared Prefs Tester + + This is a prompt From 74b0318d89b5ec4746cd4861f8573ea24c6ccea1 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 5 Aug 2024 20:51:41 +0100 Subject: [PATCH 04/30] Show replies in context of their threads (#4871) * Don't reconstruct threads from separate posts * Remove post-level dedupe for now * Change repost dedupe condition to look just at length * Delete unused isThread * Delete another isThread field It is now meaningless because there's nothing special about author threads. * Narrow down slice item shape so it does not need reply * Consolidate slice validation criteria in one place * Show replies in context * Make fallback marker work * Remove misleading and now-unused property It was called rootUri but it was actually the leaf URI. Regardless, it's not used anymore. * Add by-thread dedupe to non-author feeds * Add post-level dedupe * Always count from the start This is easier to think about. * Only tuner state need to be untouched on dry run * Account for threads in reply filtering * Remove repost deduping This is already being taken care of by item-level deduping. It's also now wrong and removing too much (since it wasn't filtering for reposts directly). * Calculate rootUri correctly * Apply Following settings to all lists * Don't dedupe intentional reposts by thread * Show reply parent when ambiguous * Explicitly remove orphaned replies from following/lists * Fix thread dedupe to work across pages * Mark grandparent-blocked as orphaned * Guard tuner state change by dryRun * Remove dead code * Don't dedupe feedgen threads * Revert "Apply Following settings to all lists" This reverts commit aff86be6d37b60cc5d0ac38f22c31a4808342cf4. Let's not do this yet and have a bit more discussion. This is a chunky change already. * Reason belongs to a slice, not item * Logically feedContext belongs to the slice * Update comment to reflect latest behavior --- src/lib/api/feed-manip.ts | 424 ++++++++++++++------------ src/lib/api/feed/merge.ts | 12 - src/state/feed-feedback.tsx | 2 +- src/state/preferences/feed-tuners.tsx | 19 +- src/state/queries/post-feed.ts | 78 ++--- src/view/com/posts/Feed.tsx | 3 +- src/view/com/posts/FeedItem.tsx | 8 +- src/view/com/posts/FeedSlice.tsx | 33 +- 8 files changed, 279 insertions(+), 300 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 7ddb79434a..b8fc586ec4 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,118 @@ 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 -} - -function toSliceItem(feedViewPost: FeedViewPost): FeedSliceItem { - return { - post: feedViewPost.post, - reply: feedViewPost.reply, - } + record: AppBskyFeedPost.Record + parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined + isParentBlocked: boolean } 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,30 +159,7 @@ 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) { + getAllAuthors(): AppBskyActorDefs.ProfileViewBasic[] { const feedPost = this._feedPost const authors = [feedPost.post.author] if (feedPost.reply) { @@ -127,167 +173,149 @@ export class FeedViewPostsSlice { authors.push(feedPost.reply.root.author) } } - return authors.every(a => a.did === userDid || a.viewer?.following) + return authors } } 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.getAllAuthors()) + ) { + 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 +326,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) + !isFollowingAll(slice.getAllAuthors(), userDid) ) { slices.splice(i, 1) + i-- } } return slices @@ -324,6 +354,7 @@ export class FeedTuner { return ( tuner: FeedTuner, slices: FeedViewPostsSlice[], + _dryRun: boolean, ): FeedViewPostsSlice[] => { const candidateSlices = slices.slice() @@ -332,7 +363,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 +389,15 @@ 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 - } - } - return undefined +function areSameAuthor(authors: AppBskyActorDefs.ProfileViewBasic[]): boolean { + const dids = authors.map(a => a.did) + const set = new Set(dids) + return set.size === 1 +} + +function isFollowingAll( + authors: AppBskyActorDefs.ProfileViewBasic[], + userDid: string, +): boolean { + return authors.every(a => a.did === userDid || a.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/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index 59b4bf78a4..aab2737e5a 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -123,7 +123,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) { toString({ item: postItem.uri, event: 'app.bsky.feed.defs#interactionSeen', - feedContext: postItem.feedContext, + feedContext: slice.feedContext, }), ) sendToFeed() diff --git a/src/state/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/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/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 7623ff37e3..46bf4a5fd4 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' @@ -472,7 +471,7 @@ let Feed = ({ } else if (item.type === progressGuideInterstitialType) { return } else if (item.type === 'slice') { - if (item.slice.rootUri === FALLBACK_MARKER_POST.post.uri) { + if (item.slice.isFallbackMarker) { // HACK // tell the user we fell back to discover // see home.ts (feed api) for more info diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 9ddc54a989..2c2e2163d7 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -345,11 +345,9 @@ let FeedItemInner = ({ postHref={href} onOpenAuthor={onOpenAuthor} /> - {!isThreadChild && - showReplyTo && - (parentAuthor || isParentBlocked) && ( - - )} + {showReplyTo && (parentAuthor || isParentBlocked) && ( + + )} { - 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 ( From 5bf7f3769d005e7e606e4b10327eb7467f59f0aa Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 6 Aug 2024 00:30:58 +0100 Subject: [PATCH 05/30] [Persisted] Fork web and native, make it synchronous on the web (#4872) * Delete logic for legacy storage * Delete superfluous tests At this point these tests aren't testing anything useful, let's just get rid of them. * Inline store.ts methods into persisted/index.ts * Fork persisted/index.ts into index.web.ts * Remove non-essential code and comments from both forks * Remove async/await from web fork of persisted/index.ts * Remove unused return * Enforce that forked types match --- src/state/persisted/__tests__/fixtures.ts | 67 ------- src/state/persisted/__tests__/index.test.ts | 49 ----- src/state/persisted/__tests__/migrate.test.ts | 93 ---------- src/state/persisted/__tests__/schema.test.ts | 21 --- src/state/persisted/index.ts | 108 ++++++----- src/state/persisted/index.web.ts | 126 +++++++++++++ src/state/persisted/legacy.ts | 167 ------------------ src/state/persisted/store.ts | 44 ----- src/state/persisted/types.ts | 9 + src/view/screens/Settings/index.tsx | 19 +- 10 files changed, 187 insertions(+), 516 deletions(-) delete mode 100644 src/state/persisted/__tests__/fixtures.ts delete mode 100644 src/state/persisted/__tests__/index.test.ts delete mode 100644 src/state/persisted/__tests__/migrate.test.ts delete mode 100644 src/state/persisted/__tests__/schema.test.ts create mode 100644 src/state/persisted/index.web.ts delete mode 100644 src/state/persisted/legacy.ts delete mode 100644 src/state/persisted/store.ts create mode 100644 src/state/persisted/types.ts 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..639e4e47f5 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -1,49 +1,35 @@ -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, schema} 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 + const stored = await readFromStorage() if (!stored) { - logger.debug('persisted state: initializing default storage') - await store.write(defaults) // opt: init new store + await writeToStorage(defaults) } - _state = stored || defaults // return new store - logger.debug('persisted state: initialized') + _state = stored || defaults } catch (e) { logger.error('persisted state: failed to load root state from storage', { message: e, }) - // AsyncStorage failure, but we can still continue in memory - return defaults } } +init satisfies PersistedApi['init'] export function get(key: K): Schema[K] { return _state[key] } +get satisfies PersistedApi['get'] export async function write( key: K, @@ -51,47 +37,55 @@ export async function write( ): Promise { 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, - }) + await writeToStorage(_state) } catch (e) { logger.error(`persisted state: failed writing root state to storage`, { message: e, }) } } +write satisfies PersistedApi['write'] -export function onUpdate(cb: () => void): () => void { - _emitter.addListener('update', cb) - return () => _emitter.removeListener('update', cb) +export function onUpdate(_cb: () => void): () => void { + return () => {} } +onUpdate satisfies PersistedApi['onUpdate'] -async function onBroadcastMessage({data}: MessageEvent) { - // validate event - if (typeof data === 'object' && data.event === UPDATE_EVENT) { - 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`, - ) - } - } catch (e) { - logger.error( - `persisted state: failed handling update from broadcast channel`, - { - message: e, - }, - ) - } +export async function clearStorage() { + try { + await AsyncStorage.removeItem(BSKY_STORAGE) + } catch (e: any) { + logger.error(`persisted store: failed to clear`, {message: e.toString()}) + } +} +clearStorage satisfies PersistedApi['clearStorage'] + +async function writeToStorage(value: Schema) { + schema.parse(value) + await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) +} + +async function readFromStorage(): 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 } } diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts new file mode 100644 index 0000000000..50f28b6b8a --- /dev/null +++ b/src/state/persisted/index.web.ts @@ -0,0 +1,126 @@ +import EventEmitter from 'eventemitter3' + +import BroadcastChannel from '#/lib/broadcast' +import {logger} from '#/logger' +import {defaults, Schema, schema} 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 + + try { + const stored = readFromStorage() + if (!stored) { + writeToStorage(defaults) + } + _state = stored || defaults + } catch (e) { + logger.error('persisted state: failed to load root state from storage', { + message: e, + }) + } +} +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 { + try { + _state[key] = value + writeToStorage(_state) + // must happen on next tick, otherwise the tab will read stale storage data + setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0) + } catch (e) { + logger.error(`persisted state: failed writing root state to storage`, { + message: e, + }) + } +} +write satisfies PersistedApi['write'] + +export function onUpdate(cb: () => void): () => void { + _emitter.addListener('update', cb) + return () => _emitter.removeListener('update', cb) +} +onUpdate satisfies PersistedApi['onUpdate'] + +export async function clearStorage() { + try { + localStorage.removeItem(BSKY_STORAGE) + } catch (e: any) { + logger.error(`persisted store: failed to clear`, {message: e.toString()}) + } +} +clearStorage satisfies PersistedApi['clearStorage'] + +async function onBroadcastMessage({data}: MessageEvent) { + if (typeof data === 'object' && data.event === UPDATE_EVENT) { + try { + // read next state, possibly updated by another tab + const next = readFromStorage() + + if (next) { + _state = next + _emitter.emit('update') + } else { + logger.error( + `persisted state: handled update update from broadcast channel, but found no data`, + ) + } + } catch (e) { + logger.error( + `persisted state: failed handling update from broadcast channel`, + { + message: e, + }, + ) + } + } +} + +function writeToStorage(value: Schema) { + schema.parse(value) + localStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) +} + +function readFromStorage(): Schema | undefined { + const rawData = localStorage.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 + } +} 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/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..95852f7960 --- /dev/null +++ b/src/state/persisted/types.ts @@ -0,0 +1,9 @@ +import type {Schema} from './schema' + +export type PersistedApi = { + init(): Promise + get(key: K): Schema[K] + write(key: K, value: Schema[K]): Promise + onUpdate(_cb: () => void): () => void + clearStorage: () => Promise +} diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index c33be7d542..a75fec5463 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -20,8 +20,7 @@ import {useQueryClient} from '@tanstack/react-query' import {isNative} from '#/platform/detection' import {useModalControls} from '#/state/modals' -import {clearLegacyStorage} from '#/state/persisted/legacy' -import {clear as clearStorage} from '#/state/persisted/store' +import {clearStorage} from '#/state/persisted' import { useInAppBrowser, useSetInAppBrowser, @@ -299,10 +298,6 @@ export function SettingsScreen({}: Props) { await clearStorage() Toast.show(_(msg`Storage cleared, you need to restart the app now.`)) }, [_]) - const clearAllLegacyStorage = React.useCallback(async () => { - await clearLegacyStorage() - Toast.show(_(msg`Legacy storage cleared, you need to restart the app now.`)) - }, [_]) const deactivateAccountControl = useDialogControl() const onPressDeactivateAccount = React.useCallback(() => { @@ -863,18 +858,6 @@ export function SettingsScreen({}: Props) { Reset onboarding state - - - - Clear all legacy storage data (restart after this) - - - Date: Tue, 6 Aug 2024 01:03:27 +0100 Subject: [PATCH 06/30] [Persisted] Fix the race condition causing clobbered writes between tabs (#4873) * Broadcast the update in the same tick The motivation for the original code is unclear. I was not able to reproduce the described behavior and have not seen it mentioned on the web. I'll assume that this was a misunderstanding. * Remove defensive programming The only places in this code that we can expect to throw are schema.parse(), JSON.parse(), JSON.stringify(), and localStorage.getItem/setItem/removeItem. Let's push try/catch'es where we expect them to be necessary. * Don't write or clobber defaults Writing defaults to local storage is unnecessary. We would write them as a part of next update anyway. So I'm removing that to reduce the number of moving pieces. However, we do need to be wary of _state being set to defaults. Because _state gets mutated on write. We don't want to mutate the defaults object. To avoid having to think about this, let's copy on write. We don't write to this object very often. * Refactor: extract tryParse * Refactor: move string parsing into tryParse * Extract tryStringify, split logging by platform Shared data parsing/stringification errors are always logged. Storage errors are only logged on native because we trust the web APIs to work. * Add a layer of caching to readFromStorage to web We're going to be doing a read on every write so let's add a fast path that avoids parsing and validating. * Fix the race condition causing clobbered writes between tabs --- src/state/persisted/index.ts | 74 +++++++++---------- src/state/persisted/index.web.ts | 123 +++++++++++++++---------------- src/state/persisted/schema.ts | 43 ++++++++++- 3 files changed, 133 insertions(+), 107 deletions(-) diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 639e4e47f5..95f8148505 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -1,7 +1,12 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import {logger} from '#/logger' -import {defaults, Schema, schema} from '#/state/persisted/schema' +import { + defaults, + Schema, + tryParse, + tryStringify, +} from '#/state/persisted/schema' import {PersistedApi} from './types' export type {PersistedAccount, Schema} from '#/state/persisted/schema' @@ -12,16 +17,9 @@ const BSKY_STORAGE = 'BSKY_STORAGE' let _state: Schema = defaults export async function init() { - try { - const stored = await readFromStorage() - if (!stored) { - await writeToStorage(defaults) - } - _state = stored || defaults - } catch (e) { - logger.error('persisted state: failed to load root state from storage', { - message: e, - }) + const stored = await readFromStorage() + if (stored) { + _state = stored } } init satisfies PersistedApi['init'] @@ -35,14 +33,11 @@ export async function write( key: K, value: Schema[K], ): Promise { - try { - _state[key] = value - await writeToStorage(_state) - } catch (e) { - logger.error(`persisted state: failed writing root state to storage`, { - message: e, - }) + _state = { + ..._state, + [key]: value, } + await writeToStorage(_state) } write satisfies PersistedApi['write'] @@ -61,31 +56,28 @@ export async function clearStorage() { clearStorage satisfies PersistedApi['clearStorage'] async function writeToStorage(value: Schema) { - schema.parse(value) - await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) + const rawData = tryStringify(value) + if (rawData) { + try { + await AsyncStorage.setItem(BSKY_STORAGE, rawData) + } catch (e) { + logger.error(`persisted state: failed writing root state to storage`, { + message: e, + }) + } + } } async function readFromStorage(): 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 + 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 index 50f28b6b8a..d71b59096b 100644 --- a/src/state/persisted/index.web.ts +++ b/src/state/persisted/index.web.ts @@ -2,7 +2,12 @@ import EventEmitter from 'eventemitter3' import BroadcastChannel from '#/lib/broadcast' import {logger} from '#/logger' -import {defaults, Schema, schema} from '#/state/persisted/schema' +import { + defaults, + Schema, + tryParse, + tryStringify, +} from '#/state/persisted/schema' import {PersistedApi} from './types' export type {PersistedAccount, Schema} from '#/state/persisted/schema' @@ -18,17 +23,9 @@ const _emitter = new EventEmitter() export async function init() { broadcast.onmessage = onBroadcastMessage - - try { - const stored = readFromStorage() - if (!stored) { - writeToStorage(defaults) - } - _state = stored || defaults - } catch (e) { - logger.error('persisted state: failed to load root state from storage', { - message: e, - }) + const stored = readFromStorage() + if (stored) { + _state = stored } } init satisfies PersistedApi['init'] @@ -42,16 +39,20 @@ export async function write( key: K, value: Schema[K], ): Promise { - try { - _state[key] = value - writeToStorage(_state) - // must happen on next tick, otherwise the tab will read stale storage data - setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0) - } catch (e) { - logger.error(`persisted state: failed writing root state to storage`, { - message: e, - }) + 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. } + _state = { + ..._state, + [key]: value, + } + writeToStorage(_state) + broadcast.postMessage({event: UPDATE_EVENT}) } write satisfies PersistedApi['write'] @@ -65,62 +66,54 @@ export async function clearStorage() { try { localStorage.removeItem(BSKY_STORAGE) } catch (e: any) { - logger.error(`persisted store: failed to clear`, {message: e.toString()}) + // Expected on the web in private mode. } } clearStorage satisfies PersistedApi['clearStorage'] async function onBroadcastMessage({data}: MessageEvent) { if (typeof data === 'object' && data.event === UPDATE_EVENT) { - try { - // read next state, possibly updated by another tab - const next = readFromStorage() - - if (next) { - _state = next - _emitter.emit('update') - } else { - logger.error( - `persisted state: handled update update from broadcast channel, but found no data`, - ) - } - } catch (e) { + // read next state, possibly updated by another tab + const next = readFromStorage() + if (next) { + _state = next + _emitter.emit('update') + } else { logger.error( - `persisted state: failed handling update from broadcast channel`, - { - message: e, - }, + `persisted state: handled update update from broadcast channel, but found no data`, ) } } } function writeToStorage(value: Schema) { - schema.parse(value) - localStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) -} - -function readFromStorage(): Schema | undefined { - const rawData = localStorage.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 + 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/schema.ts b/src/state/persisted/schema.ts index 399a7e7932..0b652a1f00 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({ @@ -133,3 +134,43 @@ export const defaults: Schema = { kawaii: false, hasCheckedForStarterPack: false, } + +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 + } +} From 686d5ebb535710dd8c96aa694b4cd1f7913ff3fa Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 6 Aug 2024 01:30:52 +0100 Subject: [PATCH 07/30] [Persisted] Make broadcast subscriptions granular by key (#4874) * Add fast path for guaranteed noop updates * Change persisted.onUpdate() API to take a key * Implement granular broadcast listeners --- src/state/invites.tsx | 5 ++- src/state/persisted/index.ts | 5 ++- src/state/persisted/index.web.ts | 41 ++++++++++++++++--- src/state/persisted/types.ts | 5 ++- src/state/preferences/alt-text-required.tsx | 9 ++-- src/state/preferences/autoplay.tsx | 4 +- src/state/preferences/disable-haptics.tsx | 4 +- .../preferences/external-embeds-prefs.tsx | 4 +- src/state/preferences/hidden-posts.tsx | 4 +- src/state/preferences/in-app-browser.tsx | 4 +- src/state/preferences/kawaii.tsx | 4 +- src/state/preferences/languages.tsx | 4 +- src/state/preferences/large-alt-badge.tsx | 9 ++-- src/state/preferences/used-starter-packs.tsx | 9 ++-- src/state/session/index.tsx | 4 +- src/state/shell/color-mode.tsx | 13 ++++-- src/state/shell/onboarding.tsx | 9 ++-- 17 files changed, 95 insertions(+), 42 deletions(-) diff --git a/src/state/invites.tsx b/src/state/invites.tsx index 6a0d1b5900..0d40caf258 100644 --- a/src/state/invites.tsx +++ b/src/state/invites.tsx @@ -1,4 +1,5 @@ import React from 'react' + import * as persisted from '#/state/persisted' type StateContext = persisted.Schema['invites'] @@ -35,8 +36,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) React.useEffect(() => { - return persisted.onUpdate(() => { - setState(persisted.get('invites')) + return persisted.onUpdate('invites', nextInvites => { + setState(nextInvites) }) }, [setState]) diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 95f8148505..6f4beae2ca 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -41,7 +41,10 @@ export async function write( } write satisfies PersistedApi['write'] -export function onUpdate(_cb: () => void): () => void { +export function onUpdate( + _key: K, + _cb: (v: Schema[K]) => void, +): () => void { return () => {} } onUpdate satisfies PersistedApi['onUpdate'] diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts index d71b59096b..7521776bc0 100644 --- a/src/state/persisted/index.web.ts +++ b/src/state/persisted/index.web.ts @@ -47,18 +47,36 @@ export async function write( // 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: UPDATE_EVENT}) + broadcast.postMessage({event: {type: UPDATE_EVENT, key}}) + broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading } write satisfies PersistedApi['write'] -export function onUpdate(cb: () => void): () => void { - _emitter.addListener('update', cb) - return () => _emitter.removeListener('update', cb) +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'] @@ -72,12 +90,23 @@ export async function clearStorage() { clearStorage satisfies PersistedApi['clearStorage'] async function onBroadcastMessage({data}: MessageEvent) { - if (typeof data === 'object' && data.event === UPDATE_EVENT) { + 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 - _emitter.emit('update') + 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`, diff --git a/src/state/persisted/types.ts b/src/state/persisted/types.ts index 95852f7960..fd39079bf8 100644 --- a/src/state/persisted/types.ts +++ b/src/state/persisted/types.ts @@ -4,6 +4,9 @@ export type PersistedApi = { init(): Promise get(key: K): Schema[K] write(key: K, value: Schema[K]): Promise - onUpdate(_cb: () => void): () => void + 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/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/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/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/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/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, }) } }) From b291a1ed8a1706f30f117d691d85508ffad342f2 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 6 Aug 2024 16:42:42 +0100 Subject: [PATCH 08/30] Show more replies in Following (different heuristic) (#4880) --- src/lib/api/feed-manip.ts | 94 ++++++++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index b8fc586ec4..ae3e84b99d 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -25,6 +25,13 @@ type FeedSliceItem = { isParentBlocked: boolean } +type AuthorContext = { + author: AppBskyActorDefs.ProfileViewBasic + parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined + grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined + rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined +} + export class FeedViewPostsSlice { _reactKey: string _feedPost: FeedViewPost @@ -159,21 +166,29 @@ export class FeedViewPostsSlice { return !!this.items.find(item => item.post.uri === uri) } - getAllAuthors(): AppBskyActorDefs.ProfileViewBasic[] { + 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 + return { + author, + parentAuthor, + grandparentAuthor, + rootAuthor, + } } } @@ -252,7 +267,7 @@ export class FeedTuner { !slice.isRepost && // This is not perfect but it's close as we can get to // detecting threads without having to peek ahead. - !areSameAuthor(slice.getAllAuthors()) + !areSameAuthor(slice.getAuthors()) ) { slices.splice(i, 1) i-- @@ -333,7 +348,7 @@ export class FeedTuner { if ( slice.isReply && !slice.isRepost && - !isFollowingAll(slice.getAllAuthors(), userDid) + !shouldDisplayReplyInFollowing(slice.getAuthors(), userDid) ) { slices.splice(i, 1) i-- @@ -389,15 +404,64 @@ export class FeedTuner { } } -function areSameAuthor(authors: AppBskyActorDefs.ProfileViewBasic[]): boolean { - const dids = authors.map(a => a.did) - const set = new Set(dids) - return set.size === 1 +function areSameAuthor(authors: AuthorContext): boolean { + const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors + const authorDid = author.did + if (parentAuthor && parentAuthor.did !== authorDid) { + return false + } + if (grandparentAuthor && grandparentAuthor.did !== authorDid) { + return false + } + if (rootAuthor && rootAuthor.did !== authorDid) { + return false + } + return true } -function isFollowingAll( - authors: AppBskyActorDefs.ProfileViewBasic[], +function shouldDisplayReplyInFollowing( + authors: AuthorContext, userDid: string, ): boolean { - return authors.every(a => a.did === userDid || a.viewer?.following) + const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors + if (!isSelfOrFollowing(author, userDid)) { + // Only show replies from self or people you follow. + return false + } + if (!parentAuthor || !grandparentAuthor || !rootAuthor) { + // Don't surface orphaned reply subthreads. + return false + } + if ( + parentAuthor.did === author.did && + grandparentAuthor.did === author.did && + rootAuthor.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.did !== author.did && + isSelfOrFollowing(parentAuthor, userDid) + ) { + return true + } + if ( + grandparentAuthor.did !== author.did && + isSelfOrFollowing(grandparentAuthor, userDid) + ) { + return true + } + if (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) } From 5845e08eeea151deb75bb21ceaa33f7a973870e3 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 6 Aug 2024 17:12:27 +0100 Subject: [PATCH 09/30] Show own replies before follows' replies in threads (#4882) --- src/state/queries/post-thread.ts | 13 ++++++++++++- src/view/com/post-thread/PostThread.tsx | 9 +++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index db85e8a177..c01b96ed81 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -136,6 +136,7 @@ export function sortThread( node: ThreadNode, opts: UsePreferencesQueryResponse['threadViewPrefs'], modCache: ThreadModerationCache, + currentDid: string | undefined, ): ThreadNode { if (node.type !== 'post') { return node @@ -159,6 +160,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 +206,7 @@ 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)) } return node } diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index a6c1a46487..b7eaedd363 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -89,7 +89,7 @@ export function PostThread({ onCanReply: (canReply: boolean) => void onPressReply: () => unknown }) { - const {hasSession} = useSession() + const {hasSession, currentAccount} = useSession() const {_} = useLingui() const t = useTheme() const {isMobile, isTabletOrMobile} = useWebMediaQueries() @@ -154,6 +154,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) { @@ -167,8 +168,8 @@ export function PostThread({ if (!threadViewPrefs || !thread) return null return createThreadSkeleton( - sortThread(thread, threadViewPrefs, threadModerationCache), - hasSession, + sortThread(thread, threadViewPrefs, threadModerationCache, currentDid), + !!currentDid, treeView, threadModerationCache, hiddenRepliesState !== HiddenRepliesState.Hide, @@ -176,7 +177,7 @@ export function PostThread({ }, [ thread, preferences?.threadViewPrefs, - hasSession, + currentDid, treeView, threadModerationCache, hiddenRepliesState, From 753a2334082d279b504e164a1f50c0b0a8169f2b Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 6 Aug 2024 11:21:59 -0700 Subject: [PATCH 10/30] Tweak feed manip to show cases of A -> B without further children (#4883) --- src/lib/api/feed-manip.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index ae3e84b99d..61de795a14 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -428,32 +428,34 @@ function shouldDisplayReplyInFollowing( // Only show replies from self or people you follow. return false } - if (!parentAuthor || !grandparentAuthor || !rootAuthor) { - // Don't surface orphaned reply subthreads. - return false - } if ( - parentAuthor.did === author.did && - grandparentAuthor.did === author.did && - rootAuthor.did === author.did + (!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.did !== author.did && isSelfOrFollowing(rootAuthor, userDid)) { + if ( + rootAuthor && + rootAuthor.did !== author.did && + isSelfOrFollowing(rootAuthor, userDid) + ) { return true } return false From b701e8c68c1122bf138575804af41260ec1c436d Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 7 Aug 2024 16:56:12 +0100 Subject: [PATCH 11/30] [Video] Authed video upload (#4885) * add service auth call * update API package --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- package.json | 2 +- src/state/queries/video/video-upload.ts | 26 +++++++++++++++------ src/state/queries/video/video-upload.web.ts | 22 +++++++++++++---- yarn.lock | 8 +++---- 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 3d053bc83b..faeee448c9 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "^0.12.26", + "@atproto/api": "0.12.29", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/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/yarn.lock b/yarn.lock index 6fa8805125..16547aa3ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,10 +34,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@^0.12.26": - version "0.12.26" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.26.tgz#940888466522cc9ff8c03d8164dc39221b29d9ca" - integrity sha512-RH0ymOGbDfT8IL8eNzzY+hwtyTgknHfkzUVqRd0sstNblvTf8WGpDR2FSTveiiMR3OpVO6zG8fRYVzBfmY1+pA== +"@atproto/api@0.12.29": + version "0.12.29" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.29.tgz#95a19202c2f0eec4c955909685be11009ba9b9a1" + integrity sha512-PyzPLjGWR0qNOMrmj3Nt3N5NuuANSgOk/33Bu3j+rFjjPrHvk9CI6iQPU6zuDaDCoyOTRJRafw8X/aMQw+ilgw== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.0" From fff2c079c2554861764974aaeeb56f79a25ba82a Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 7 Aug 2024 18:47:51 +0100 Subject: [PATCH 12/30] [Videos] Video player - PR #2 - better web support (#4732) * attempt some sort of "usurping" system * polling-based active video approach * split into inner component again * click to steal active video * disable findAndActivateVideo on native * new intersectionobserver approach - wip * fix types * disable perf optimisation to allow overflow * make active player indicator subtler, clean up video utils * partially fix double-playing * start working on controls * fullscreen API * get buttons working somewhat * rm source from where it shouldn't be * use video elem as source of truth * fix keyboard nav + mute state * new icons, add fullscreen + time + fix play * unmount when far offscreen + round 2dp * listen globally to clicks rather than blur event * move controls to new file * reduce quality when not active * add hover state to buttons * stop propagation of videoplayer click * move around autoplay effects * increase background contrast * add subtitles button * add stopPropagation to root of video player * clean up VideoWebControls * fix chrome * change quality based on focused state * use autoLevelCapping instead of nextLevel * get subtitle track from stream * always use hlsjs * rework hls into a ref * render player earlier, allowing preload * add error boundary * clean up component structure and organisation * rework fullscreen API * disable fullscreen on iPhone * don't play when ready on pause * debounce buffering * simplify giant list of event listeners * update pref * reduce prop drilling * minimise rerenders in `ActiveViewContext` * restore prop drilling --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> Co-authored-by: Hailey --- ...rowsDiagonalIn_stroke2_corner0_rounded.svg | 1 + ...rowsDiagonalIn_stroke2_corner2_rounded.svg | 1 + ...owsDiagonalOut_stroke2_corner0_rounded.svg | 1 + ...owsDiagonalOut_stroke2_corner2_rounded.svg | 1 + .../cc_filled_stroke2_corner0_rounded.svg | 1 + assets/icons/cc_stroke2_corner0_rounded.svg | 1 + assets/icons/pause_filled_corner0_rounded.svg | 1 + assets/icons/pause_filled_corner2_rounded.svg | 1 + .../icons/pause_stroke2_corner0_rounded.svg | 1 + .../icons/pause_stroke2_corner2_rounded.svg | 1 + assets/icons/play_filled_corner0_rounded.svg | 1 + assets/icons/play_stroke2_corner0_rounded.svg | 1 + src/components/icons/ArrowsDiagonal.tsx | 17 + src/components/icons/CC.tsx | 9 + src/components/icons/Pause.tsx | 17 + src/components/icons/Play.tsx | 8 + src/platform/detection.ts | 1 + .../Messages/Conversation/MessagesList.tsx | 3 - src/state/persisted/schema.ts | 2 + src/state/preferences/index.tsx | 6 +- src/state/preferences/subtitles.tsx | 42 ++ src/view/com/posts/FeedItem.tsx | 1 - src/view/com/util/List.tsx | 2 - src/view/com/util/List.web.tsx | 22 +- .../util/post-embeds/ActiveVideoContext.tsx | 89 ++- src/view/com/util/post-embeds/VideoEmbed.tsx | 12 +- .../com/util/post-embeds/VideoEmbed.web.tsx | 190 ++++++ .../com/util/post-embeds/VideoEmbedInner.tsx | 7 +- .../util/post-embeds/VideoEmbedInner.web.tsx | 121 ++-- .../util/post-embeds/VideoPlayerContext.tsx | 10 +- .../com/util/post-embeds/VideoWebControls.tsx | 16 + .../util/post-embeds/VideoWebControls.web.tsx | 587 ++++++++++++++++++ 32 files changed, 1087 insertions(+), 87 deletions(-) create mode 100644 assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg create mode 100644 assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg create mode 100644 assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg create mode 100644 assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg create mode 100644 assets/icons/cc_filled_stroke2_corner0_rounded.svg create mode 100644 assets/icons/cc_stroke2_corner0_rounded.svg create mode 100644 assets/icons/pause_filled_corner0_rounded.svg create mode 100644 assets/icons/pause_filled_corner2_rounded.svg create mode 100644 assets/icons/pause_stroke2_corner0_rounded.svg create mode 100644 assets/icons/pause_stroke2_corner2_rounded.svg create mode 100644 assets/icons/play_filled_corner0_rounded.svg create mode 100644 assets/icons/play_stroke2_corner0_rounded.svg create mode 100644 src/components/icons/ArrowsDiagonal.tsx create mode 100644 src/components/icons/CC.tsx create mode 100644 src/components/icons/Pause.tsx create mode 100644 src/state/preferences/subtitles.tsx create mode 100644 src/view/com/util/post-embeds/VideoEmbed.web.tsx create mode 100644 src/view/com/util/post-embeds/VideoWebControls.tsx create mode 100644 src/view/com/util/post-embeds/VideoWebControls.web.tsx 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/src/components/icons/ArrowsDiagonal.tsx b/src/components/icons/ArrowsDiagonal.tsx new file mode 100644 index 0000000000..3f9ae40e0f --- /dev/null +++ b/src/components/icons/ArrowsDiagonal.tsx @@ -0,0 +1,17 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowsDiagonalOut_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z', +}) + +export const ArrowsDiagonalIn_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z', +}) + +export const ArrowsDiagonalOut_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z', +}) + +export const ArrowsDiagonalIn_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z', +}) diff --git a/src/components/icons/CC.tsx b/src/components/icons/CC.tsx new file mode 100644 index 0000000000..da2e7c5dba --- /dev/null +++ b/src/components/icons/CC.tsx @@ -0,0 +1,9 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CC_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z', +}) + +export const CC_Filled_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z', +}) diff --git a/src/components/icons/Pause.tsx b/src/components/icons/Pause.tsx new file mode 100644 index 0000000000..927f285a00 --- /dev/null +++ b/src/components/icons/Pause.tsx @@ -0,0 +1,17 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Pause_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z', +}) + +export const Pause_Filled_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z', +}) + +export const Pause_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z', +}) + +export const Pause_Filled_Corner2_Rounded = createSinglePathSVG({ + path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z', +}) diff --git a/src/components/icons/Play.tsx b/src/components/icons/Play.tsx index acf421d57c..176b24f281 100644 --- a/src/components/icons/Play.tsx +++ b/src/components/icons/Play.tsx @@ -1,5 +1,13 @@ import {createSinglePathSVG} from './TEMPLATE' +export const Play_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z', +}) + +export const Play_Filled_Corner0_Rounded = createSinglePathSVG({ + path: 'M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z', +}) + export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({ path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z', }) diff --git a/src/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/state/persisted/schema.ts b/src/state/persisted/schema.ts index 0b652a1f00..331a111a2e 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -91,6 +91,7 @@ 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()), }) @@ -133,6 +134,7 @@ export const defaults: Schema = { disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(), kawaii: false, hasCheckedForStarterPack: false, + subtitlesEnabled: true, } export function tryParse(rawData: string): Schema | undefined { 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/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/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 2c2e2163d7..a6e721d43c 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -507,7 +507,6 @@ const styles = StyleSheet.create({ paddingRight: 15, // @ts-ignore web only -prf cursor: 'pointer', - overflow: 'hidden', }, replyLine: { width: 2, diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index e1a10e4741..9d9b1d8026 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -28,8 +28,6 @@ export type ListProps = Omit< // Web only prop to contain the scroll to the container rather than the window disableFullWindowScroll?: boolean sideBorders?: boolean - // Web only prop to disable a perf optimization (which would otherwise be on). - disableContainStyle?: boolean } export type ListRef = React.MutableRefObject diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 5aa699356d..5f89cfbbc9 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -4,11 +4,10 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean import {batchedUpdates} from '#/lib/batchedUpdates' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {usePalette} from '#/lib/hooks/usePalette' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useScrollHandlers} from '#/lib/ScrollContext' -import {isSafari} from 'lib/browser' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {addStyle} from 'lib/styles' +import {addStyle} from '#/lib/styles' export type ListMethods = any // TODO: Better types. export type ListProps = Omit< @@ -26,8 +25,6 @@ export type ListProps = Omit< // Web only prop to contain the scroll to the container rather than the window disableFullWindowScroll?: boolean sideBorders?: boolean - // Web only prop to disable a perf optimization (which would otherwise be on). - disableContainStyle?: boolean } export type ListRef = React.MutableRefObject // TODO: Better types. @@ -60,7 +57,6 @@ function ListImpl( extraData, style, sideBorders = true, - disableContainStyle, ...props }: ListProps, ref: React.Ref, @@ -364,7 +360,6 @@ function ListImpl( renderItem={renderItem} extraData={extraData} onItemSeen={onItemSeen} - disableContainStyle={disableContainStyle} /> ) })} @@ -442,7 +437,6 @@ let Row = function RowImpl({ renderItem, extraData: _unused, onItemSeen, - disableContainStyle, }: { item: ItemT index: number @@ -452,7 +446,6 @@ let Row = function RowImpl({ | ((data: {index: number; item: any; separators: any}) => React.ReactNode) extraData: any onItemSeen: ((item: any) => void) | undefined - disableContainStyle?: boolean }): React.ReactNode { const rowRef = React.useRef(null) const intersectionTimeout = React.useRef(undefined) @@ -501,11 +494,8 @@ let Row = function RowImpl({ return null } - const shouldDisableContainStyle = disableContainStyle || isSafari return ( - + {renderItem({item, index, separators: null as any})} ) @@ -576,10 +566,6 @@ const styles = StyleSheet.create({ marginLeft: 'auto', marginRight: 'auto', }, - contain: { - // @ts-ignore web only - contain: 'layout paint', - }, minHeightViewport: { // @ts-ignore web only minHeight: '100vh', diff --git a/src/view/com/util/post-embeds/ActiveVideoContext.tsx b/src/view/com/util/post-embeds/ActiveVideoContext.tsx index 6804436a7e..d18dfc0908 100644 --- a/src/view/com/util/post-embeds/ActiveVideoContext.tsx +++ b/src/view/com/util/post-embeds/ActiveVideoContext.tsx @@ -1,37 +1,103 @@ -import React, {useCallback, useId, useMemo, useState} from 'react' +import React, { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react' +import {useWindowDimensions} from 'react-native' +import {isNative} from '#/platform/detection' import {VideoPlayerProvider} from './VideoPlayerContext' const ActiveVideoContext = React.createContext<{ activeViewId: string | null setActiveView: (viewId: string, src: string) => void + sendViewPosition: (viewId: string, y: number) => void } | null>(null) export function ActiveVideoProvider({children}: {children: React.ReactNode}) { const [activeViewId, setActiveViewId] = useState(null) + const activeViewLocationRef = useRef(Infinity) const [source, setSource] = useState(null) + const {height: windowHeight} = useWindowDimensions() + + // minimising re-renders by using refs + const manuallySetRef = useRef(false) + const activeViewIdRef = useRef(activeViewId) + useEffect(() => { + activeViewIdRef.current = activeViewId + }, [activeViewId]) + + const setActiveView = useCallback( + (viewId: string, src: string) => { + setActiveViewId(viewId) + setSource(src) + manuallySetRef.current = true + // we don't know the exact position, but it's definitely on screen + // so just guess that it's in the middle. Any value is fine + // so long as it's not offscreen + activeViewLocationRef.current = windowHeight / 2 + }, + [windowHeight], + ) + + const sendViewPosition = useCallback( + (viewId: string, y: number) => { + if (isNative) return + + if (viewId === activeViewIdRef.current) { + activeViewLocationRef.current = y + } else { + if ( + distanceToIdealPosition(y) < + distanceToIdealPosition(activeViewLocationRef.current) + ) { + // if the old view was manually set, only usurp if the old view is offscreen + if ( + manuallySetRef.current && + withinViewport(activeViewLocationRef.current) + ) { + return + } + + setActiveViewId(viewId) + activeViewLocationRef.current = y + manuallySetRef.current = false + } + } + + function distanceToIdealPosition(yPos: number) { + return Math.abs(yPos - windowHeight / 2.5) + } + + function withinViewport(yPos: number) { + return yPos > 0 && yPos < windowHeight + } + }, + [windowHeight], + ) const value = useMemo( () => ({ activeViewId, - setActiveView: (viewId: string, src: string) => { - setActiveViewId(viewId) - setSource(src) - }, + setActiveView, + sendViewPosition, }), - [activeViewId], + [activeViewId, setActiveView, sendViewPosition], ) return ( - + {children} ) } -export function useActiveVideoView() { +export function useActiveVideoView({source}: {source: string}) { const context = React.useContext(ActiveVideoContext) if (!context) { throw new Error('useActiveVideo must be used within a ActiveVideoProvider') @@ -41,7 +107,12 @@ export function useActiveVideoView() { return { active: context.activeViewId === id, setActive: useCallback( - (source: string) => context.setActiveView(id, source), + () => context.setActiveView(id, source), + [context, id, source], + ), + currentActiveView: context.activeViewId, + sendPosition: useCallback( + (y: number) => context.sendViewPosition(id, y), [context, id], ), } diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index 5e5293a553..429312d9e1 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -11,10 +11,10 @@ import {VideoEmbedInner} from './VideoEmbedInner' export function VideoEmbed({source}: {source: string}) { const t = useTheme() - const {active, setActive} = useActiveVideoView() + const {active, setActive} = useActiveVideoView({source}) const {_} = useLingui() - const onPress = useCallback(() => setActive(source), [setActive, source]) + const onPress = useCallback(() => setActive(), [setActive]) return ( {active ? ( - + ) : ( + )} + + ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.tsx index ef06787097..9b1fd54fb6 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner.tsx @@ -13,7 +13,12 @@ import {atoms as a} from '#/alf' import {Text} from '#/components/Typography' import {useVideoPlayer} from './VideoPlayerContext' -export const VideoEmbedInner = ({}: {source: string}) => { +export function VideoEmbedInner({}: { + source: string + active: boolean + setActive: () => void + onScreen: boolean +}) { const player = useVideoPlayer() const aref = useAnimatedRef() const {height: windowHeight} = useWindowDimensions() diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx index cb02743c6f..f5f47db506 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx @@ -1,52 +1,93 @@ -import React, {useEffect, useRef} from 'react' +import React, {useEffect, useRef, useState} from 'react' +import {View} from 'react-native' import Hls from 'hls.js' import {atoms as a} from '#/alf' +import {Controls} from './VideoWebControls' -export const VideoEmbedInner = ({source}: {source: string}) => { +export function VideoEmbedInner({ + source, + active, + setActive, + onScreen, +}: { + source: string + active: boolean + setActive: () => void + onScreen: boolean +}) { + const containerRef = useRef(null) const ref = useRef(null) + const [focused, setFocused] = useState(false) + const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) + + const hlsRef = useRef(undefined) - // 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 + if (!ref.current) return + if (!Hls.isSupported()) throw new HLSUnsupportedError() + + const hls = new Hls({capLevelToPlayerSize: true}) + hlsRef.current = hls + + hls.attachMedia(ref.current) + hls.loadSource(source) + + // initial value, later on it's managed by Controls + hls.autoLevelCapping = 0 + + hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => { + if (data.subtitleTracks.length > 0) { + setHasSubtitleTrack(true) } + }) + + return () => { + hlsRef.current = undefined + hls.detachMedia() + hls.destroy() } }, [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 ) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 46bf4a5fd4..aa45d3acc8 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -180,6 +180,7 @@ let Feed = ({ ListHeaderComponent?: () => JSX.Element extraData?: any savedFeedConfig?: AppBskyActorDefs.SavedFeed + outsideHeaderOffset?: number }): React.ReactNode => { const theme = useTheme() const {track} = useAnalytics() diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index a6e721d43c..6660a8d9d6 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -356,7 +356,7 @@ let FeedItemInner = ({ postAuthor={post.author} onOpenEmbed={onOpenEmbed} /> - {__DEV__ && gate('videos') && ( + {gate('video_debug') && ( )} ( ) { const isScrolledDown = useSharedValue(false) const pal = usePalette('default') + const dedupe = useDedupe() function handleScrolledDownChange(didScrollDown: boolean) { onScrolledDownChange?.(didScrollDown) @@ -77,6 +80,8 @@ function ListImpl( runOnJS(handleScrolledDownChange)(didScrollDown) } } + + runOnJS(dedupe)(updateActiveViewAsync) }, // Note: adding onMomentumBegin here makes simulator scroll // lag on Android. So either don't add it, or figure out why. diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index 429312d9e1..887efac1ab 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -1,21 +1,20 @@ -import React, {useCallback} from 'react' +import React from 'react' import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {VideoEmbedInnerNative} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' +import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army' import {useActiveVideoView} from './ActiveVideoContext' -import {VideoEmbedInner} from './VideoEmbedInner' export function VideoEmbed({source}: {source: string}) { const t = useTheme() const {active, setActive} = useActiveVideoView({source}) const {_} = useLingui() - const onPress = useCallback(() => setActive(), [setActive]) - return ( - {active ? ( - - ) : ( - - )} + { + if (isActive) { + setActive() + } + }}> + {active ? ( + + ) : ( + + )} + ) } diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx index 08932f91f1..70d887283e 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx @@ -3,13 +3,15 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import { + HLSUnsupportedError, + VideoEmbedInnerWeb, +} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {Text} from '#/components/Typography' import {ErrorBoundary} from '../ErrorBoundary' import {useActiveVideoView} from './ActiveVideoContext' -import {VideoEmbedInner} from './VideoEmbedInner' -import {HLSUnsupportedError} from './VideoEmbedInner.web' export function VideoEmbed({source}: {source: string}) { const t = useTheme() @@ -60,7 +62,7 @@ export function VideoEmbed({source}: {source: string}) { - void - onScreen: boolean -}) { - 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/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx new file mode 100644 index 0000000000..cc356fb069 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -0,0 +1,96 @@ +import React, {useEffect, useRef, useState} from 'react' +import {Pressable, View} from 'react-native' +import {VideoPlayer, VideoView} from 'expo-video' + +import {useVideoPlayer} from 'view/com/util/post-embeds/VideoPlayerContext' +import {android, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' + +export function VideoEmbedInnerNative() { + const player = useVideoPlayer() + const ref = useRef(null) + + return ( + + + ref.current?.enterFullscreen()} + /> + + ) +} + +function Controls({ + player, + enterFullscreen, +}: { + player: VideoPlayer + enterFullscreen: () => void +}) { + const [duration, setDuration] = useState(() => Math.floor(player.duration)) + const [currentTime, setCurrentTime] = useState(() => + Math.floor(player.currentTime), + ) + + const timeRemaining = duration - currentTime + const minutes = Math.floor(timeRemaining / 60) + const seconds = String(timeRemaining % 60).padStart(2, '0') + + useEffect(() => { + const interval = setInterval(() => { + // duration gets reset to 0 on loop + if (player.duration) setDuration(Math.floor(player.duration)) + setCurrentTime(Math.floor(player.currentTime)) + // how often should we update the time? + // 1000 gets out of sync with the video time + }, 250) + + return () => { + clearInterval(interval) + } + }, [player]) + + if (isNaN(timeRemaining)) { + return null + } + + return ( + + + + {minutes}:{seconds} + + + + + ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx new file mode 100644 index 0000000000..59da5be42a --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx @@ -0,0 +1,3 @@ +export function VideoEmbedInnerNative() { + throw new Error('VideoEmbedInnerNative may not be used on native.') +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx new file mode 100644 index 0000000000..8664aae142 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx @@ -0,0 +1,3 @@ +export function VideoEmbedInnerWeb() { + throw new Error('VideoEmbedInnerWeb may not be used on native.') +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx similarity index 88% rename from src/view/com/util/post-embeds/VideoEmbedInner.web.tsx rename to src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx index f5f47db506..c0021d9bb7 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -5,17 +5,23 @@ import Hls from 'hls.js' import {atoms as a} from '#/alf' import {Controls} from './VideoWebControls' -export function VideoEmbedInner({ +export function VideoEmbedInnerWeb({ source, active, setActive, onScreen, }: { source: string - active: boolean - setActive: () => void - onScreen: boolean + active?: boolean + setActive?: () => void + onScreen?: boolean }) { + if (active == null || setActive == null || onScreen == null) { + throw new Error( + 'active, setActive, and onScreen are required VideoEmbedInner props on web.', + ) + } + const containerRef = useRef(null) const ref = useRef(null) const [focused, setFocused] = useState(false) diff --git a/src/view/com/util/post-embeds/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx similarity index 100% rename from src/view/com/util/post-embeds/VideoWebControls.tsx rename to src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx diff --git a/src/view/com/util/post-embeds/VideoWebControls.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx similarity index 99% rename from src/view/com/util/post-embeds/VideoWebControls.web.tsx rename to src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx index 2843664be8..7caaf3abf7 100644 --- a/src/view/com/util/post-embeds/VideoWebControls.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx @@ -11,12 +11,12 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import type Hls from 'hls.js' -import {isIPhoneWeb} from '#/platform/detection' +import {isIPhoneWeb} from 'platform/detection' import { useAutoplayDisabled, useSetSubtitlesEnabled, useSubtitlesEnabled, -} from '#/state/preferences' +} from 'state/preferences' import {atoms as a, useTheme, web} from '#/alf' import {Button} from '#/components/Button' import {useInteractionState} from '#/components/hooks/useInteractionState' From b3092413dd21b58340e4cec739770f6d10a70248 Mon Sep 17 00:00:00 2001 From: Hailey Date: Wed, 7 Aug 2024 17:13:29 -0700 Subject: [PATCH 14/30] Add logging of selected feed preference when displaying the following feed (#4789) --- src/lib/statsig/events.ts | 6 ++++++ src/view/screens/Home.tsx | 30 ++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 159061eac9..997a366a41 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -211,6 +211,12 @@ export type LogEvents = { 'feed:interstitial:profileCard:press': {} 'feed:interstitial:feedCard: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/view/screens/Home.tsx b/src/view/screens/Home.tsx index f7cecd872d..6ee8b3ada6 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -9,7 +9,7 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {logEvent, LogEvents} from '#/lib/statsig/statsig' import {emitSoftReset} from '#/state/events' import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed' -import {FeedParams} from '#/state/queries/post-feed' +import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {usePreferencesQuery} from '#/state/queries/preferences' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {useSession} from '#/state/session' @@ -108,6 +108,30 @@ function HomeScreenReady({ } }, [selectedIndex]) + // Temporary, remove when finished debugging + const debugHasLoggedFollowingPrefs = React.useRef(false) + const debugLogFollowingPrefs = React.useCallback( + (feed: FeedDescriptor) => { + if (debugHasLoggedFollowingPrefs.current) return + if (feed !== 'following') return + logEvent('debug:followingPrefs', { + followingShowRepliesFromPref: preferences.feedViewPrefs.hideReplies + ? 'off' + : preferences.feedViewPrefs.hideRepliesByUnfollowed + ? 'following' + : 'all', + followingRepliesMinLikePref: + preferences.feedViewPrefs.hideRepliesByLikeCount, + }) + debugHasLoggedFollowingPrefs.current = true + }, + [ + preferences.feedViewPrefs.hideReplies, + preferences.feedViewPrefs.hideRepliesByLikeCount, + preferences.feedViewPrefs.hideRepliesByUnfollowed, + ], + ) + const {hasSession} = useSession() const setMinimalShellMode = useSetMinimalShellMode() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() @@ -136,6 +160,7 @@ function HomeScreenReady({ feedUrl: selectedFeed, reason: 'focus', }) + debugLogFollowingPrefs(selectedFeed) } }), ) @@ -182,8 +207,9 @@ function HomeScreenReady({ feedUrl: feed, reason, }) + debugLogFollowingPrefs(feed) }, - [allFeeds], + [allFeeds, debugLogFollowingPrefs], ) const onPressSelected = React.useCallback(() => { From 00fea10782676e3bcf7027ca3d037dcf82a25b99 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 8 Aug 2024 05:56:22 +0100 Subject: [PATCH 15/30] Include popcluster in suggestion ranking (#4887) --- src/components/FeedInterstitials.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 2e8724143d..eca1c86f00 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( From a864f69849387f0dd69251fda92e4e569bd17e94 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 8 Aug 2024 06:20:24 +0100 Subject: [PATCH 16/30] Keep interstitial fresh on refresh (#4888) --- src/view/com/posts/Feed.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index aa45d3acc8..ef46333193 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -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), @@ -358,7 +359,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 +375,7 @@ let Feed = ({ isFetched, isError, isEmpty, + lastFetchedAt, data, feedUri, feedIsDiscover, From af5262682eac63a54fb2f6351a5894b647251ab4 Mon Sep 17 00:00:00 2001 From: Minseo Lee Date: Thu, 8 Aug 2024 21:12:23 +0900 Subject: [PATCH 17/30] Added trans (#4890) --- src/lib/moderation/useModerationCauseDescription.ts | 2 +- src/view/screens/AppPasswords.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 65cbb7374e..5bf9e8a160 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -268,7 +268,7 @@ function AppPassword({ size={14} /> - Allows access to direct messages + Allows access to direct messages )} From 1e3b2d6f42839501ce47f88a19ffd477f1e2f82d Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 8 Aug 2024 09:19:51 -0500 Subject: [PATCH 18/30] ALF suggested follows in profile header (#4828) * Refactor ProfileHeaderSuggestedFollows * Load fresh data every time * Oops, missed a file * Update ProfileCard.Link usage, tweak copy --- src/lib/statsig/events.ts | 4 + src/state/queries/suggested-follows.ts | 1 + .../profile/ProfileHeaderSuggestedFollows.tsx | 379 +++++++----------- 3 files changed, 155 insertions(+), 229 deletions(-) diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 997a366a41..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,8 @@ export type LogEvents = { 'feed:interstitial:profileCard:press': {} 'feed:interstitial:feedCard:press': {} + 'profile:header:suggestedFollowsCard:press': {} + 'debug:followingPrefs': { followingShowRepliesFromPref: 'all' | 'following' | 'off' followingRepliesMinLikePref: number 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/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, '@')} - - - -