From 6298e6897fa8f4a0d296869777326cd43fb875a0 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sat, 3 Aug 2024 00:33:45 +0200 Subject: [PATCH 01/67] 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/67] 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/67] 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/67] 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/67] [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/67] [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/67] [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/67] 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/67] 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/67] 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/67] [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/67] [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/67] 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/67] 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/67] 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/67] 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/67] 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, '@')} - - - - - )} - + + { + if (isActive) { + setActive() + } + }}> + {active ? ( + + ) : ( + + )} + + ) } + +function VideoError({retry}: {error: unknown; retry: () => void}) { + return ( + + + + An error occurred while loading the video. Please try again later. + + + + + ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx index 70d887283e..5803b836df 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx @@ -1,17 +1,15 @@ import React, {useCallback, useEffect, useRef, useState} from 'react' import {View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/macro' 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 * as VideoFallback from './VideoEmbedInner/VideoFallback' export function VideoEmbed({source}: {source: string}) { const t = useTheme() @@ -138,32 +136,11 @@ function ViewportObserver({ } function VideoError({error, retry}: {error: unknown; retry: () => void}) { - const t = useTheme() - const {_} = useLingui() - const isHLS = error instanceof HLSUnsupportedError return ( - - + + {isHLS ? ( Your browser does not support the video format. Please try a @@ -174,19 +151,8 @@ function VideoError({error, retry}: {error: unknown; retry: () => void}) { An error occurred while loading the video. Please try again later. )} - - {!isHLS && ( - - )} - + + {!isHLS && } + ) } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx new file mode 100644 index 0000000000..1b46163cce --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text as TypoText} from '#/components/Typography' + +export function Container({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function Text({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function RetryButton({onPress}: {onPress: () => void}) { + const {_} = useLingui() + + return ( + + ) +} From 65d6e561d429d6759d1eef674a964d1109a1afeb Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 9 Aug 2024 16:52:23 -0700 Subject: [PATCH 40/67] [Video] Resume background audio whenever muting video audio (#4915) --- .../PlatformInfo/ExpoPlatformInfoModule.swift | 21 +++++++++++++------ .../src/PlatformInfo/index.native.ts | 4 ++-- .../src/PlatformInfo/index.ts | 8 ++++--- .../src/PlatformInfo/index.web.ts | 4 ++-- src/App.native.tsx | 2 +- .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 6 +++--- 6 files changed, 28 insertions(+), 17 deletions(-) diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift index 471f1438b0..7fd60e5fa2 100644 --- a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift +++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift @@ -13,20 +13,29 @@ public class ExpoPlatformInfoModule: Module { try? AVAudioSession.sharedInstance().setCategory(audioCategory) } - Function("setAudioMixWithOthers") { (mixWithOthers: Bool) in - var options: AVAudioSession.CategoryOptions + Function("setAudioActive") { (active: Bool) in + var categoryOptions: AVAudioSession.CategoryOptions let currentCategory = AVAudioSession.sharedInstance().category - if mixWithOthers { - options = [.mixWithOthers] + + if active { + categoryOptions = [.mixWithOthers] + try? AVAudioSession.sharedInstance().setActive(true) } else { - options = [.duckOthers] + categoryOptions = [.duckOthers] + try? AVAudioSession + .sharedInstance() + .setActive( + false, + options: [.notifyOthersOnDeactivation] + ) } + try? AVAudioSession .sharedInstance() .setCategory( currentCategory, mode: .default, - options: options + options: categoryOptions ) } } diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts index ba9dddf82a..b515206d9f 100644 --- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts @@ -9,9 +9,9 @@ export function getIsReducedMotionEnabled(): boolean { return NativeModule.getIsReducedMotionEnabled() } -export function setAudioMixWithOthers(mixWithOthers: boolean): void { +export function setAudioActive(active: boolean): void { if (Platform.OS !== 'ios') return - NativeModule.setAudioMixWithOthers(mixWithOthers) + NativeModule.setAudioActive(active) } export function setAudioCategory(audioCategory: AudioCategory): void { diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts index 5659339fba..81f8c45f4d 100644 --- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts @@ -6,11 +6,13 @@ export function getIsReducedMotionEnabled(): boolean { } /** - * Set whether the app's audio should mix with other apps' audio. + * Set whether the app's audio should mix with other apps' audio. Will also resume background music playback when `false` + * if it was previously playing. * @param mixWithOthers + * @see https://developer.apple.com/documentation/avfaudio/avaudiosession/setactiveoptions/1616603-notifyothersondeactivation */ -export function setAudioMixWithOthers(mixWithOthers: boolean): void { - throw new NotImplementedError({mixWithOthers}) +export function setAudioActive(active: boolean): void { + throw new NotImplementedError({active}) } /** diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts index cb64d00cee..61412753c9 100644 --- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts @@ -8,8 +8,8 @@ export function getIsReducedMotionEnabled(): boolean { return window.matchMedia('(prefers-reduced-motion: reduce)').matches } -export function setAudioMixWithOthers(mixWithOthers: boolean): void { - throw new NotImplementedError({mixWithOthers}) +export function setAudioActive(active: boolean): void { + throw new NotImplementedError({active}) } export function setAudioCategory(audioCategory: AudioCategory): void { diff --git a/src/App.native.tsx b/src/App.native.tsx index 71b53e7a38..8e7c53b93b 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -159,7 +159,7 @@ function App() { React.useEffect(() => { PlatformInfo.setAudioCategory(AudioCategory.Ambient) - PlatformInfo.setAudioMixWithOthers(true) + PlatformInfo.setAudioActive(true) initPersistedState().then(() => setReady(true)) }, []) diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index 33148da01a..0b48edf793 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -60,12 +60,12 @@ export function VideoEmbedInnerNative() { nativeControls={true} onEnterFullscreen={() => { PlatformInfo.setAudioCategory(AudioCategory.Playback) - PlatformInfo.setAudioMixWithOthers(false) + PlatformInfo.setAudioActive(false) player.muted = false }} onExitFullscreen={() => { PlatformInfo.setAudioCategory(AudioCategory.Ambient) - PlatformInfo.setAudioMixWithOthers(true) + PlatformInfo.setAudioActive(true) player.muted = true if (!player.playing) player.play() }} @@ -139,7 +139,7 @@ function Controls({ const category = muted ? AudioCategory.Ambient : AudioCategory.Playback PlatformInfo.setAudioCategory(category) - PlatformInfo.setAudioMixWithOthers(mix) + PlatformInfo.setAudioActive(mix) player.muted = muted }, [player]) From 836754213827ebdb3c4af2a115a70ab4364e1e94 Mon Sep 17 00:00:00 2001 From: Shubh Porwal <83606943+shubh73@users.noreply.github.com> Date: Mon, 12 Aug 2024 01:10:43 +0530 Subject: [PATCH 41/67] Fix `occurred` typo (#4919) Co-authored-by: Hailey --- src/components/dialogs/GifSelect.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index a64edcd6f0..51cfa10fb1 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -249,7 +249,7 @@ function DialogError({details}: {details?: string}) { const control = Dialog.useDialogContext() return ( - + Date: Sun, 11 Aug 2024 20:41:33 +0100 Subject: [PATCH 42/67] Mark string for localization (#4920) --- src/view/com/composer/videos/VideoTranscodeProgress.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx index db58448a30..a44b633cd5 100644 --- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx +++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native' // @ts-expect-error no type definition import ProgressPie from 'react-native-progress/Pie' import {ImagePickerAsset} from 'expo-image-picker' +import {Trans} from '@lingui/macro' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' @@ -46,7 +47,9 @@ export function VideoTranscodeProgress({ color={t.atoms.text.color} progress={progress} /> - Compressing... + + Compressing... + ) From 88f879ffe91fb7bff668c81b5a82fb4cfbd7889b Mon Sep 17 00:00:00 2001 From: Minseo Lee Date: Mon, 12 Aug 2024 06:30:18 +0900 Subject: [PATCH 43/67] Improve styles (#4916) Co-authored-by: Hailey --- src/alf/themes.ts | 28 +++---- src/components/Button.tsx | 24 +----- src/components/dialogs/GifSelect.tsx | 4 +- src/components/forms/TextField.tsx | 1 + src/lib/styles.ts | 3 +- src/lib/themes.ts | 6 +- src/view/com/pager/PagerWithHeader.web.tsx | 2 - src/view/com/pager/TabBar.tsx | 2 +- src/view/com/post-thread/PostThreadItem.tsx | 2 +- src/view/com/posts/Feed.tsx | 4 +- src/view/com/util/PostMeta.tsx | 7 +- src/view/screens/AccessibilitySettings.tsx | 2 +- src/view/screens/LanguageSettings.tsx | 2 + src/view/screens/Search/Explore.tsx | 2 +- src/view/screens/Search/Search.tsx | 3 +- src/view/screens/Settings/index.tsx | 2 +- src/view/screens/Storybook/index.tsx | 2 +- src/view/shell/Drawer.tsx | 64 +++++++++------- src/view/shell/desktop/RightNav.tsx | 9 ++- src/view/shell/desktop/Search.tsx | 83 ++------------------- 20 files changed, 89 insertions(+), 163 deletions(-) diff --git a/src/alf/themes.ts b/src/alf/themes.ts index ba18ee0072..f5d2247f9f 100644 --- a/src/alf/themes.ts +++ b/src/alf/themes.ts @@ -186,19 +186,19 @@ export function createThemes({ white: color.gray_0, black: color.trueBlack, - contrast_25: color.gray_1000, - contrast_50: color.gray_975, - contrast_100: color.gray_950, - contrast_200: color.gray_900, - contrast_300: color.gray_800, - contrast_400: color.gray_700, - contrast_500: color.gray_600, - contrast_600: color.gray_500, - contrast_700: color.gray_400, - contrast_800: color.gray_300, - contrast_900: color.gray_200, - contrast_950: color.gray_100, - contrast_975: color.gray_50, + contrast_25: color.gray_975, + contrast_50: color.gray_950, + contrast_100: color.gray_900, + contrast_200: color.gray_800, + contrast_300: color.gray_700, + contrast_400: color.gray_600, + contrast_500: color.gray_500, + contrast_600: color.gray_400, + contrast_700: color.gray_300, + contrast_800: color.gray_200, + contrast_900: color.gray_100, + contrast_950: color.gray_50, + contrast_975: color.gray_25, primary_25: color.primary_975, primary_50: color.primary_950, @@ -400,7 +400,7 @@ export function createThemes({ color: darkPalette.contrast_400, }, text_contrast_medium: { - color: darkPalette.contrast_700, + color: darkPalette.contrast_600, }, text_contrast_high: { color: darkPalette.contrast_900, diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 4fe0ab4b12..7881fc9b5e 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -202,28 +202,10 @@ export const Button = React.forwardRef( } else if (color === 'secondary') { if (variant === 'solid') { if (!disabled) { - baseStyles.push({ - backgroundColor: select(t.name, { - light: t.palette.contrast_25, - dim: t.palette.contrast_100, - dark: t.palette.contrast_100, - }), - }) - hoverStyles.push({ - backgroundColor: select(t.name, { - light: t.palette.contrast_50, - dim: t.palette.contrast_200, - dark: t.palette.contrast_200, - }), - }) + baseStyles.push(t.atoms.bg_contrast_25) + hoverStyles.push(t.atoms.bg_contrast_50) } else { - baseStyles.push({ - backgroundColor: select(t.name, { - light: t.palette.contrast_100, - dim: t.palette.contrast_25, - dark: t.palette.contrast_25, - }), - }) + baseStyles.push(t.atoms.bg_contrast_100) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index 51cfa10fb1..4c60c6ebeb 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -249,7 +249,9 @@ function DialogError({details}: {details?: string}) { const control = Dialog.useDialogContext() return ( - + diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 54ea9b1400..a179484000 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -480,9 +480,7 @@ let Feed = ({ // -prf return } - return ( - - ) + return } else { return null } diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 95168e8b3c..b1567c2c69 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -91,11 +91,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { {!isAndroid && ( - + · )} @@ -104,7 +100,6 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { diff --git a/src/view/screens/LanguageSettings.tsx b/src/view/screens/LanguageSettings.tsx index 390d2807b0..0f27db5229 100644 --- a/src/view/screens/LanguageSettings.tsx +++ b/src/view/screens/LanguageSettings.tsx @@ -145,6 +145,7 @@ export function LanguageSettingsScreen(_props: Props) { backgroundColor: pal.viewLight.backgroundColor, color: pal.text.color, fontSize: 14, + fontFamily: 'inherit', letterSpacing: 0.5, fontWeight: '500', paddingHorizontal: 14, @@ -236,6 +237,7 @@ export function LanguageSettingsScreen(_props: Props) { backgroundColor: pal.viewLight.backgroundColor, color: pal.text.color, fontSize: 14, + fontFamily: 'inherit', letterSpacing: 0.5, fontWeight: '500', paddingHorizontal: 14, diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx index a36c404444..650fd43548 100644 --- a/src/view/screens/Search/Explore.tsx +++ b/src/view/screens/Search/Explore.tsx @@ -571,7 +571,7 @@ export function Explore() { keyExtractor={item => item.key} // @ts-ignore web only -prf desktopFixedHeight - contentContainerStyle={{paddingBottom: 200}} + contentContainerStyle={{paddingBottom: 100}} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" /> diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 0eef5cbd66..737e4c5c35 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -783,7 +783,7 @@ let SearchInputBox = ({ }}> diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index 282b3ff5c7..71dbe8839d 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -36,7 +36,7 @@ function StorybookInner() { return ( - + {!showContainedList ? ( <> diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 4b765962a6..0e852edd1a 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -33,6 +33,7 @@ import {NavSignupCard} from '#/view/shell/NavSignupCard' import {formatCountShortOnly} from 'view/com/util/numeric/format' import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' +import {atoms as a} from '#/alf' import {useTheme as useAlfTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import { @@ -96,29 +97,42 @@ let DrawerProfileCard = ({ numberOfLines={1}> @{account.handle} - - - - {formatCountShortOnly(profile?.followersCount ?? 0)} - {' '} - - {' '} - ·{' '} - - - {formatCountShortOnly(profile?.followsCount ?? 0)} - {' '} - - - + + + + + {formatCountShortOnly(profile?.followersCount ?? 0)} + {' '} + + + + + · + + + + + {formatCountShortOnly(profile?.followsCount ?? 0)} + {' '} + + + + ) } @@ -610,7 +624,7 @@ const styles = StyleSheet.create({ backgroundColor: '#1B1919', }, main: { - paddingLeft: 20, + paddingHorizontal: 20, paddingTop: 20, }, smallSpacer: { @@ -627,14 +641,12 @@ const styles = StyleSheet.create({ }, profileCardFollowers: { marginTop: 16, - paddingRight: 10, }, menuItem: { flexDirection: 'row', alignItems: 'center', paddingVertical: 16, - paddingRight: 10, }, menuItemIconWrapper: { width: 24, diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index ed3d8212cf..fb8e6c26cb 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -11,6 +11,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s} from 'lib/styles' import {TextLink} from 'view/com/util/Link' import {Text} from 'view/com/util/text/Text' +import {atoms as a} from '#/alf' import {ProgressGuideList} from '#/components/ProgressGuide/List' import {DesktopFeeds} from './Feeds' import {DesktopSearch} from './Search' @@ -56,7 +57,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) { paddingTop: hasSession ? 0 : 18, }, ]}> - + {hasSession && ( <> -  ·  + · )} @@ -80,7 +81,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) { text={_(msg`Privacy`)} /> -  ·  + · -  ·  + · - - - - - {query ? ( - - - - Cancel - - - - ) : undefined} - - - + {query !== '' && isActive && moderationOpts && ( {isFetching && !autocompleteData?.length ? ( @@ -262,33 +226,6 @@ const styles = StyleSheet.create({ position: 'relative', width: 300, }, - search: { - paddingHorizontal: 16, - paddingVertical: 2, - width: 300, - borderRadius: 20, - }, - inputContainer: { - flexDirection: 'row', - }, - iconWrapper: { - position: 'relative', - top: 2, - paddingVertical: 7, - marginRight: 8, - }, - input: { - flex: 1, - fontSize: 18, - width: '100%', - paddingTop: 7, - paddingBottom: 7, - }, - cancelBtn: { - paddingRight: 4, - paddingLeft: 10, - paddingVertical: 7, - }, resultsContainer: { marginTop: 10, flexDirection: 'column', @@ -296,8 +233,4 @@ const styles = StyleSheet.create({ borderWidth: 1, borderRadius: 6, }, - noResults: { - textAlign: 'center', - paddingVertical: 10, - }, }) From 75c19b2dc21ddc3338a9d7ea590bb3e0e999610f Mon Sep 17 00:00:00 2001 From: Roland Crosby Date: Sun, 11 Aug 2024 19:12:36 -0400 Subject: [PATCH 44/67] Show handle in recent searches and fix truncation (#4917) Co-authored-by: Hailey --- src/view/screens/Search/Search.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 737e4c5c35..30d16506e0 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -894,13 +894,6 @@ let AutocompleteResults = ({ } AutocompleteResults = React.memo(AutocompleteResults) -function truncateText(text: string, maxLength: number) { - if (text.length > maxLength) { - return text.substring(0, maxLength) + '...' - } - return text -} - function SearchHistory({ searchHistory, selectedProfiles, @@ -965,8 +958,10 @@ function SearchHistory({ style={styles.profileAvatar as StyleProp} accessibilityIgnoresInvertColors /> - - {truncateText(profile.displayName || '', 12)} + + {profile.displayName || profile.handle} Date: Mon, 12 Aug 2024 08:14:02 -0700 Subject: [PATCH 45/67] Fix Android composer cursor bug by removing `setTimeout` from native composer `onChangeText` (#4922) --- .../com/composer/text-input/TextInput.tsx | 100 ++++++++---------- 1 file changed, 44 insertions(+), 56 deletions(-) diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index cb16e3c666..f69c895693 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -85,71 +85,59 @@ export const TextInput = forwardRef(function TextInputImpl( const pastSuggestedUris = useRef(new Set()) const prevDetectedUris = useRef(new Map()) const onChangeText = useCallback( - (newText: string) => { - /* - * This is a hack to bump the rendering of our styled - * `textDecorated` to _after_ whatever processing is happening - * within the `PasteInput` library. Without this, the elements in - * `textDecorated` are not correctly painted to screen. - * - * NB: we tried a `0` timeout as well, but only positive values worked. - * - * @see https://github.com/bluesky-social/social-app/issues/929 - */ - setTimeout(async () => { - const mayBePaste = newText.length > prevLength.current + 1 + async (newText: string) => { + const mayBePaste = newText.length > prevLength.current + 1 - const newRt = new RichText({text: newText}) - newRt.detectFacetsWithoutResolution() - setRichText(newRt) + const newRt = new RichText({text: newText}) + newRt.detectFacetsWithoutResolution() + setRichText(newRt) - const prefix = getMentionAt( - newText, - textInputSelection.current?.start || 0, - ) - if (prefix) { - setAutocompletePrefix(prefix.value) - } else if (autocompletePrefix) { - setAutocompletePrefix('') - } + const prefix = getMentionAt( + newText, + textInputSelection.current?.start || 0, + ) + if (prefix) { + setAutocompletePrefix(prefix.value) + } else if (autocompletePrefix) { + setAutocompletePrefix('') + } - const nextDetectedUris = new Map() - if (newRt.facets) { - for (const facet of newRt.facets) { - for (const feature of facet.features) { - if (AppBskyRichtextFacet.isLink(feature)) { - if (isUriImage(feature.uri)) { - const res = await downloadAndResize({ - uri: feature.uri, - width: POST_IMG_MAX.width, - height: POST_IMG_MAX.height, - mode: 'contain', - maxSize: POST_IMG_MAX.size, - timeout: 15e3, - }) + const nextDetectedUris = new Map() + if (newRt.facets) { + for (const facet of newRt.facets) { + for (const feature of facet.features) { + if (AppBskyRichtextFacet.isLink(feature)) { + if (isUriImage(feature.uri)) { + const res = await downloadAndResize({ + uri: feature.uri, + width: POST_IMG_MAX.width, + height: POST_IMG_MAX.height, + mode: 'contain', + maxSize: POST_IMG_MAX.size, + timeout: 15e3, + }) - if (res !== undefined) { - onPhotoPasted(res.path) - } - } else { - nextDetectedUris.set(feature.uri, {facet, rt: newRt}) + if (res !== undefined) { + onPhotoPasted(res.path) } + } else { + nextDetectedUris.set(feature.uri, {facet, rt: newRt}) } } } } - const suggestedUri = suggestLinkCardUri( - mayBePaste, - nextDetectedUris, - prevDetectedUris.current, - pastSuggestedUris.current, - ) - prevDetectedUris.current = nextDetectedUris - if (suggestedUri) { - onNewLink(suggestedUri) - } - prevLength.current = newText.length - }, 1) + } + const suggestedUri = suggestLinkCardUri( + mayBePaste, + nextDetectedUris, + prevDetectedUris.current, + pastSuggestedUris.current, + ) + prevDetectedUris.current = nextDetectedUris + if (suggestedUri) { + onNewLink(suggestedUri) + } + prevLength.current = newText.length }, [setRichText, autocompletePrefix, onPhotoPasted, onNewLink], ) From ae883e2df7bc53baca215fba527fe113e71cb5c2 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 12 Aug 2024 13:46:33 -0700 Subject: [PATCH 46/67] rm from swift (#4923) --- .../ios/PlatformInfo/ExpoPlatformInfoModule.swift | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift index 7fd60e5fa2..b61066beda 100644 --- a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift +++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift @@ -14,14 +14,9 @@ public class ExpoPlatformInfoModule: Module { } Function("setAudioActive") { (active: Bool) in - var categoryOptions: AVAudioSession.CategoryOptions - let currentCategory = AVAudioSession.sharedInstance().category - if active { - categoryOptions = [.mixWithOthers] try? AVAudioSession.sharedInstance().setActive(true) } else { - categoryOptions = [.duckOthers] try? AVAudioSession .sharedInstance() .setActive( @@ -29,14 +24,6 @@ public class ExpoPlatformInfoModule: Module { options: [.notifyOthersOnDeactivation] ) } - - try? AVAudioSession - .sharedInstance() - .setCategory( - currentCategory, - mode: .default, - options: categoryOptions - ) } } } From 7df2327424e948e54b9731e5ab651e889f38a772 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 12 Aug 2024 14:00:15 -0700 Subject: [PATCH 47/67] Upgrade API, implement XRPC rework (#4857) Co-authored-by: Matthieu Sieben --- index.js | 9 +- index.web.js | 5 +- jest/test-pds.ts | 4 +- package.json | 4 +- patches/@atproto+lexicon+0.4.0.patch | 28 -- src/lib/api/api-polyfill.ts | 85 ---- src/lib/api/api-polyfill.web.ts | 3 - src/lib/api/feed/custom.ts | 25 +- src/lib/api/index.ts | 39 +- src/lib/api/upload-blob.ts | 82 ++++ src/lib/api/upload-blob.web.ts | 26 ++ src/lib/media/manip.ts | 8 +- src/screens/SignupQueued.tsx | 2 +- src/state/queries/preferences/index.ts | 4 +- src/state/session/__tests__/session-test.ts | 72 ++-- src/state/session/agent.ts | 44 +- src/state/session/index.tsx | 24 +- src/state/session/logging.ts | 2 +- yarn.lock | 437 ++++++++++++++------ 19 files changed, 543 insertions(+), 360 deletions(-) delete mode 100644 patches/@atproto+lexicon+0.4.0.patch delete mode 100644 src/lib/api/api-polyfill.ts delete mode 100644 src/lib/api/api-polyfill.web.ts create mode 100644 src/lib/api/upload-blob.ts create mode 100644 src/lib/api/upload-blob.web.ts diff --git a/index.js b/index.js index 7630d0538a..2f13ce1ea1 100644 --- a/index.js +++ b/index.js @@ -1,14 +1,11 @@ import 'react-native-gesture-handler' // must be first -import {LogBox} from 'react-native' - import '#/platform/polyfills' -import {IS_TEST} from '#/env' + +import {LogBox} from 'react-native' import {registerRootComponent} from 'expo' -import {doPolyfill} from '#/lib/api/api-polyfill' import App from '#/App' - -doPolyfill() +import {IS_TEST} from '#/env' if (IS_TEST) { LogBox.ignoreAllLogs() // suppress all logs in tests diff --git a/index.web.js b/index.web.js index 9623734512..be75bc772e 100644 --- a/index.web.js +++ b/index.web.js @@ -1,9 +1,8 @@ import '#/platform/markBundleStartTime' - import '#/platform/polyfills' + import {registerRootComponent} from 'expo' -import {doPolyfill} from '#/lib/api/api-polyfill' + import App from '#/App' -doPolyfill() registerRootComponent(App) diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 2fe623ca98..bfcc970c2f 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -156,7 +156,7 @@ class Mocker { } async createUser(name: string) { - const agent = new BskyAgent({service: this.agent.service}) + const agent = new BskyAgent({service: this.service}) const inviteRes = await agent.api.com.atproto.server.createInviteCode( {useCount: 1}, @@ -332,7 +332,7 @@ class Mocker { } async createInvite(forAccount: string) { - const agent = new BskyAgent({service: this.agent.service}) + const agent = new BskyAgent({service: this.service}) await agent.api.com.atproto.server.createInviteCode( {useCount: 1, forAccount}, { diff --git a/package.json b/package.json index 7c6e13afb6..a4523d988f 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.29", + "@atproto/api": "0.13.0", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", @@ -208,7 +208,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/dev-env": "^0.3.5", + "@atproto/dev-env": "^0.3.39", "@babel/core": "^7.23.2", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", diff --git a/patches/@atproto+lexicon+0.4.0.patch b/patches/@atproto+lexicon+0.4.0.patch deleted file mode 100644 index 4643db32af..0000000000 --- a/patches/@atproto+lexicon+0.4.0.patch +++ /dev/null @@ -1,28 +0,0 @@ -diff --git a/node_modules/@atproto/lexicon/dist/validators/complex.js b/node_modules/@atproto/lexicon/dist/validators/complex.js -index 32d7798..9d688b7 100644 ---- a/node_modules/@atproto/lexicon/dist/validators/complex.js -+++ b/node_modules/@atproto/lexicon/dist/validators/complex.js -@@ -113,7 +113,22 @@ function object(lexicons, path, def, value) { - if (value[key] === null && nullableProps.has(key)) { - continue; - } -- const propDef = def.properties[key]; -+ const propDef = def.properties[key] -+ if (typeof value[key] === 'undefined' && !requiredProps.has(key)) { -+ // Fast path for non-required undefined props. -+ if ( -+ propDef.type === 'integer' || -+ propDef.type === 'boolean' || -+ propDef.type === 'string' -+ ) { -+ if (typeof propDef.default === 'undefined') { -+ continue -+ } -+ } else { -+ // Other types have no defaults. -+ continue -+ } -+ } - const propPath = `${path}/${key}`; - const validated = (0, util_1.validateOneOf)(lexicons, propPath, propDef, value[key]); - const propValue = validated.success ? validated.value : value[key]; diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts deleted file mode 100644 index e3aec76316..0000000000 --- a/src/lib/api/api-polyfill.ts +++ /dev/null @@ -1,85 +0,0 @@ -import RNFS from 'react-native-fs' -import {BskyAgent, jsonToLex, stringifyLex} from '@atproto/api' - -const GET_TIMEOUT = 15e3 // 15s -const POST_TIMEOUT = 60e3 // 60s - -export function doPolyfill() { - BskyAgent.configure({fetch: fetchHandler}) -} - -interface FetchHandlerResponse { - status: number - headers: Record - body: any -} - -async function fetchHandler( - reqUri: string, - reqMethod: string, - reqHeaders: Record, - reqBody: any, -): Promise { - const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type'] - if (reqMimeType && reqMimeType.startsWith('application/json')) { - reqBody = stringifyLex(reqBody) - } else if ( - typeof reqBody === 'string' && - (reqBody.startsWith('/') || reqBody.startsWith('file:')) - ) { - if (reqBody.endsWith('.jpeg') || reqBody.endsWith('.jpg')) { - // HACK - // React native has a bug that inflates the size of jpegs on upload - // we get around that by renaming the file ext to .bin - // see https://github.com/facebook/react-native/issues/27099 - // -prf - const newPath = reqBody.replace(/\.jpe?g$/, '.bin') - await RNFS.moveFile(reqBody, newPath) - reqBody = newPath - } - // NOTE - // React native treats bodies with {uri: string} as file uploads to pull from cache - // -prf - reqBody = {uri: reqBody} - } - - const controller = new AbortController() - const to = setTimeout( - () => controller.abort(), - reqMethod === 'post' ? POST_TIMEOUT : GET_TIMEOUT, - ) - - const res = await fetch(reqUri, { - method: reqMethod, - headers: reqHeaders, - body: reqBody, - signal: controller.signal, - }) - - const resStatus = res.status - const resHeaders: Record = {} - res.headers.forEach((value: string, key: string) => { - resHeaders[key] = value - }) - const resMimeType = resHeaders['Content-Type'] || resHeaders['content-type'] - let resBody - if (resMimeType) { - if (resMimeType.startsWith('application/json')) { - resBody = jsonToLex(await res.json()) - } else if (resMimeType.startsWith('text/')) { - resBody = await res.text() - } else if (resMimeType === 'application/vnd.ipld.car') { - resBody = await res.arrayBuffer() - } else { - throw new Error('Non-supported mime type') - } - } - - clearTimeout(to) - - return { - status: resStatus, - headers: resHeaders, - body: resBody, - } -} diff --git a/src/lib/api/api-polyfill.web.ts b/src/lib/api/api-polyfill.web.ts deleted file mode 100644 index 1ad22b3d02..0000000000 --- a/src/lib/api/api-polyfill.web.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function doPolyfill() { - // no polyfill is needed on web -} diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index eb54dd29c1..6db96a8d63 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -1,7 +1,6 @@ import { AppBskyFeedDefs, AppBskyFeedGetFeed as GetCustomFeed, - AtpAgent, BskyAgent, } from '@atproto/api' @@ -51,7 +50,7 @@ export class CustomFeedAPI implements FeedAPI { const agent = this.agent const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed) - const res = agent.session + const res = agent.did ? await this.agent.app.bsky.feed.getFeed( { ...this.params, @@ -106,34 +105,32 @@ async function loggedOutFetch({ let contentLangs = getContentLanguages().join(',') // manually construct fetch call so we can add the `lang` cache-busting param - let res = await AtpAgent.fetch!( + let res = await fetch( `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${ cursor ? `&cursor=${cursor}` : '' }&limit=${limit}&lang=${contentLangs}`, - 'GET', - {'Accept-Language': contentLangs}, - undefined, + {method: 'GET', headers: {'Accept-Language': contentLangs}}, ) - if (res.body?.feed?.length) { + let data = res.ok ? await res.json() : null + if (data?.feed?.length) { return { success: true, - data: res.body, + data, } } // no data, try again with language headers removed - res = await AtpAgent.fetch!( + res = await fetch( `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${ cursor ? `&cursor=${cursor}` : '' }&limit=${limit}`, - 'GET', - {'Accept-Language': ''}, - undefined, + {method: 'GET', headers: {'Accept-Language': ''}}, ) - if (res.body?.feed?.length) { + data = res.ok ? await res.json() : null + if (data?.feed?.length) { return { success: true, - data: res.body, + data, } } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 12e30bf6c1..658ed78de4 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -6,7 +6,6 @@ import { AppBskyFeedThreadgate, BskyAgent, ComAtprotoLabelDefs, - ComAtprotoRepoUploadBlob, RichText, } from '@atproto/api' import {AtUri} from '@atproto/api' @@ -15,10 +14,13 @@ import {logger} from '#/logger' import {ThreadgateSetting} from '#/state/queries/threadgate' import {isNetworkError} from 'lib/strings/errors' import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip' -import {isNative, isWeb} from 'platform/detection' +import {isNative} from 'platform/detection' import {ImageModel} from 'state/models/media/image' import {LinkMeta} from '../link-meta/link-meta' import {safeDeleteAsync} from '../media/manip' +import {uploadBlob} from './upload-blob' + +export {uploadBlob} export interface ExternalEmbedDraft { uri: string @@ -28,25 +30,6 @@ export interface ExternalEmbedDraft { localThumb?: ImageModel } -export async function uploadBlob( - agent: BskyAgent, - blob: string, - encoding: string, -): Promise { - if (isWeb) { - // `blob` should be a data uri - return agent.uploadBlob(convertDataURIToUint8Array(blob), { - encoding, - }) - } else { - // `blob` should be a path to a file in the local FS - return agent.uploadBlob( - blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts - {encoding}, - ) - } -} - interface PostOpts { rawText: string replyTo?: string @@ -301,7 +284,7 @@ export async function createThreadgate( const postUrip = new AtUri(postUri) await agent.api.com.atproto.repo.putRecord({ - repo: agent.session!.did, + repo: agent.accountDid, collection: 'app.bsky.feed.threadgate', rkey: postUrip.rkey, record: { @@ -312,15 +295,3 @@ export async function createThreadgate( }, }) } - -// helpers -// = - -function convertDataURIToUint8Array(uri: string): Uint8Array { - var raw = window.atob(uri.substring(uri.indexOf(';base64,') + 8)) - var binary = new Uint8Array(new ArrayBuffer(raw.length)) - for (let i = 0; i < raw.length; i++) { - binary[i] = raw.charCodeAt(i) - } - return binary -} diff --git a/src/lib/api/upload-blob.ts b/src/lib/api/upload-blob.ts new file mode 100644 index 0000000000..0814d5185b --- /dev/null +++ b/src/lib/api/upload-blob.ts @@ -0,0 +1,82 @@ +import RNFS from 'react-native-fs' +import {BskyAgent, ComAtprotoRepoUploadBlob} from '@atproto/api' + +/** + * @param encoding Allows overriding the blob's type + */ +export async function uploadBlob( + agent: BskyAgent, + input: string | Blob, + encoding?: string, +): Promise { + if (typeof input === 'string' && input.startsWith('file:')) { + const blob = await asBlob(input) + return agent.uploadBlob(blob, {encoding}) + } + + if (typeof input === 'string' && input.startsWith('/')) { + const blob = await asBlob(`file://${input}`) + return agent.uploadBlob(blob, {encoding}) + } + + if (typeof input === 'string' && input.startsWith('data:')) { + const blob = await fetch(input).then(r => r.blob()) + return agent.uploadBlob(blob, {encoding}) + } + + if (input instanceof Blob) { + return agent.uploadBlob(input, {encoding}) + } + + throw new TypeError(`Invalid uploadBlob input: ${typeof input}`) +} + +async function asBlob(uri: string): Promise { + return withSafeFile(uri, async safeUri => { + // Note + // Android does not support `fetch()` on `file://` URIs. for this reason, we + // use XMLHttpRequest instead of simply calling: + + // return fetch(safeUri.replace('file:///', 'file:/')).then(r => r.blob()) + + return await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.onload = () => resolve(xhr.response) + xhr.onerror = () => reject(new Error('Failed to load blob')) + xhr.responseType = 'blob' + xhr.open('GET', safeUri, true) + xhr.send(null) + }) + }) +} + +// HACK +// React native has a bug that inflates the size of jpegs on upload +// we get around that by renaming the file ext to .bin +// see https://github.com/facebook/react-native/issues/27099 +// -prf +async function withSafeFile( + uri: string, + fn: (path: string) => Promise, +): Promise { + if (uri.endsWith('.jpeg') || uri.endsWith('.jpg')) { + // Since we don't "own" the file, we should avoid renaming or modifying it. + // Instead, let's copy it to a temporary file and use that (then remove the + // temporary file). + const newPath = uri.replace(/\.jpe?g$/, '.bin') + try { + await RNFS.copyFile(uri, newPath) + } catch { + // Failed to copy the file, just use the original + return await fn(uri) + } + try { + return await fn(newPath) + } finally { + // Remove the temporary file + await RNFS.unlink(newPath) + } + } else { + return fn(uri) + } +} diff --git a/src/lib/api/upload-blob.web.ts b/src/lib/api/upload-blob.web.ts new file mode 100644 index 0000000000..d3c52190c1 --- /dev/null +++ b/src/lib/api/upload-blob.web.ts @@ -0,0 +1,26 @@ +import {BskyAgent, ComAtprotoRepoUploadBlob} from '@atproto/api' + +/** + * @note It is recommended, on web, to use the `file` instance of the file + * selector input element, rather than a `data:` URL, to avoid + * loading the file into memory. `File` extends `Blob` "file" instances can + * be passed directly to this function. + */ +export async function uploadBlob( + agent: BskyAgent, + input: string | Blob, + encoding?: string, +): Promise { + if (typeof input === 'string' && input.startsWith('data:')) { + const blob = await fetch(input).then(r => r.blob()) + return agent.uploadBlob(blob, {encoding}) + } + + if (input instanceof Blob) { + return agent.uploadBlob(input, { + encoding, + }) + } + + throw new TypeError(`Invalid uploadBlob input: ${typeof input}`) +} diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 3e647004bb..3f01e98c5e 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -218,13 +218,7 @@ export async function safeDeleteAsync(path: string) { // Normalize is necessary for Android, otherwise it doesn't delete. const normalizedPath = normalizePath(path) try { - await Promise.allSettled([ - deleteAsync(normalizedPath, {idempotent: true}), - // HACK: Try this one too. Might exist due to api-polyfill hack. - deleteAsync(normalizedPath.replace(/\.jpe?g$/, '.bin'), { - idempotent: true, - }), - ]) + await deleteAsync(normalizedPath, {idempotent: true}) } catch (e) { console.error('Failed to delete file', e) } diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx index 4e4fedcfae..69ef93618d 100644 --- a/src/screens/SignupQueued.tsx +++ b/src/screens/SignupQueued.tsx @@ -40,7 +40,7 @@ export function SignupQueued() { const res = await agent.com.atproto.temp.checkSignupQueue() if (res.data.activated) { // ready to go, exchange the access token for a usable one and kick off onboarding - await agent.refreshSession() + await agent.sessionManager.refreshSession() if (!isSignupQueued(agent.session?.accessJwt)) { onboardingDispatch({type: 'start'}) } diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 6991f8647b..ab866d5e2a 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -37,14 +37,14 @@ export function usePreferencesQuery() { refetchOnWindowFocus: true, queryKey: preferencesQueryKey, queryFn: async () => { - if (agent.session?.did === undefined) { + if (!agent.did) { return DEFAULT_LOGGED_OUT_PREFERENCES } else { const res = await agent.getPreferences() // save to local storage to ensure there are labels on initial requests saveLabelers( - agent.session.did, + agent.did, res.moderationPrefs.labelers.map(l => l.did), ) diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index 486604169a..731b66b0e9 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -27,7 +27,7 @@ describe('session', () => { `) const agent = new BskyAgent({service: 'https://alice.com'}) - agent.session = { + agent.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -118,7 +118,7 @@ describe('session', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -166,7 +166,7 @@ describe('session', () => { `) const agent2 = new BskyAgent({service: 'https://bob.com'}) - agent2.session = { + agent2.sessionManager.session = { active: true, did: 'bob-did', handle: 'bob.test', @@ -230,7 +230,7 @@ describe('session', () => { `) const agent3 = new BskyAgent({service: 'https://alice.com'}) - agent3.session = { + agent3.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice-updated.test', @@ -294,7 +294,7 @@ describe('session', () => { `) const agent4 = new BskyAgent({service: 'https://jay.com'}) - agent4.session = { + agent4.sessionManager.session = { active: true, did: 'jay-did', handle: 'jay.test', @@ -445,7 +445,7 @@ describe('session', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -502,7 +502,7 @@ describe('session', () => { `) const agent2 = new BskyAgent({service: 'https://alice.com'}) - agent2.session = { + agent2.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -553,7 +553,7 @@ describe('session', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -598,7 +598,7 @@ describe('session', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -606,7 +606,7 @@ describe('session', () => { refreshJwt: 'alice-refresh-jwt-1', } const agent2 = new BskyAgent({service: 'https://bob.com'}) - agent2.session = { + agent2.sessionManager.session = { active: true, did: 'bob-did', handle: 'bob.test', @@ -678,7 +678,7 @@ describe('session', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -695,7 +695,7 @@ describe('session', () => { expect(state.accounts.length).toBe(1) expect(state.currentAgentState.did).toBe('alice-did') - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice-updated.test', @@ -748,7 +748,7 @@ describe('session', () => { } `) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice-updated.test', @@ -801,7 +801,7 @@ describe('session', () => { } `) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice-updated.test', @@ -859,7 +859,7 @@ describe('session', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -876,7 +876,7 @@ describe('session', () => { expect(state.accounts.length).toBe(1) expect(state.currentAgentState.did).toBe('alice-did') - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice-updated.test', @@ -907,7 +907,7 @@ describe('session', () => { ]) expect(lastState === state).toBe(true) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice-updated.test', @@ -931,7 +931,7 @@ describe('session', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -940,7 +940,7 @@ describe('session', () => { } const agent2 = new BskyAgent({service: 'https://bob.com'}) - agent2.session = { + agent2.sessionManager.session = { active: true, did: 'bob-did', handle: 'bob.test', @@ -965,7 +965,7 @@ describe('session', () => { expect(state.accounts.length).toBe(2) expect(state.currentAgentState.did).toBe('bob-did') - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice-updated.test', @@ -1032,7 +1032,7 @@ describe('session', () => { } `) - agent2.session = { + agent2.sessionManager.session = { active: true, did: 'bob-did', handle: 'bob-updated.test', @@ -1099,7 +1099,7 @@ describe('session', () => { // Ignore other events for inactive agent. const lastState = state - agent1.session = undefined + agent1.sessionManager.session = undefined state = run(state, [ { type: 'received-agent-event', @@ -1126,7 +1126,7 @@ describe('session', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -1135,7 +1135,7 @@ describe('session', () => { } const agent2 = new BskyAgent({service: 'https://bob.com'}) - agent2.session = { + agent2.sessionManager.session = { active: true, did: 'bob-did', handle: 'bob.test', @@ -1162,7 +1162,7 @@ describe('session', () => { expect(state.accounts.length).toBe(1) expect(state.currentAgentState.did).toBe('bob-did') - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -1188,7 +1188,7 @@ describe('session', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -1206,7 +1206,7 @@ describe('session', () => { expect(state.accounts.length).toBe(1) expect(state.currentAgentState.did).toBe('alice-did') - agent1.session = undefined + agent1.sessionManager.session = undefined state = run(state, [ { type: 'received-agent-event', @@ -1255,7 +1255,7 @@ describe('session', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -1273,7 +1273,7 @@ describe('session', () => { expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.currentAgentState.did).toBe('alice-did') - agent1.session = undefined + agent1.sessionManager.session = undefined state = run(state, [ { type: 'received-agent-event', @@ -1320,7 +1320,7 @@ describe('session', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -1338,7 +1338,7 @@ describe('session', () => { expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.currentAgentState.did).toBe('alice-did') - agent1.session = undefined + agent1.sessionManager.session = undefined state = run(state, [ { type: 'received-agent-event', @@ -1385,7 +1385,7 @@ describe('session', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) - agent1.session = { + agent1.sessionManager.session = { active: true, did: 'alice-did', handle: 'alice.test', @@ -1393,7 +1393,7 @@ describe('session', () => { refreshJwt: 'alice-refresh-jwt-1', } const agent2 = new BskyAgent({service: 'https://bob.com'}) - agent2.session = { + agent2.sessionManager.session = { active: true, did: 'bob-did', handle: 'bob.test', @@ -1416,7 +1416,7 @@ describe('session', () => { expect(state.currentAgentState.did).toBe('bob-did') const anotherTabAgent1 = new BskyAgent({service: 'https://jay.com'}) - anotherTabAgent1.session = { + anotherTabAgent1.sessionManager.session = { active: true, did: 'jay-did', handle: 'jay.test', @@ -1424,7 +1424,7 @@ describe('session', () => { refreshJwt: 'jay-refresh-jwt-1', } const anotherTabAgent2 = new BskyAgent({service: 'https://alice.com'}) - anotherTabAgent2.session = { + anotherTabAgent2.sessionManager.session = { active: true, did: 'bob-did', handle: 'bob.test', @@ -1492,7 +1492,7 @@ describe('session', () => { `) const anotherTabAgent3 = new BskyAgent({service: 'https://clarence.com'}) - anotherTabAgent3.session = { + anotherTabAgent3.sessionManager.session = { active: true, did: 'clarence-did', handle: 'clarence.test', diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 4456ab0bf9..73be34bb27 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -1,4 +1,9 @@ -import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api' +import { + AtpPersistSessionHandler, + AtpSessionData, + AtpSessionEvent, + BskyAgent, +} from '@atproto/api' import {TID} from '@atproto/common-web' import {networkRetry} from '#/lib/async/retry' @@ -20,6 +25,8 @@ import { import {SessionAccount} from './types' import {isSessionExpired, isSignupQueued} from './util' +type SetPersistSessionHandler = (cb: AtpPersistSessionHandler) => void + export function createPublicAgent() { configureModerationForGuest() // Side effect but only relevant for tests return new BskyAgent({service: PUBLIC_BSKY_SERVICE}) @@ -32,10 +39,11 @@ export async function createAgentAndResume( did: string, event: AtpSessionEvent, ) => void, + setPersistSessionHandler: SetPersistSessionHandler, ) { const agent = new BskyAgent({service: storedAccount.service}) if (storedAccount.pdsUrl) { - agent.pdsUrl = agent.api.xrpc.uri = new URL(storedAccount.pdsUrl) + agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl) } const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency') const moderation = configureModerationForAccount(agent, storedAccount) @@ -43,9 +51,8 @@ export async function createAgentAndResume( if (isSessionExpired(storedAccount)) { await networkRetry(1, () => agent.resumeSession(prevSession)) } else { - agent.session = prevSession + agent.sessionManager.session = prevSession if (!storedAccount.signupQueued) { - // Intentionally not awaited to unblock the UI: networkRetry(3, () => agent.resumeSession(prevSession)).catch( (e: any) => { logger.error(`networkRetry failed to resume session`, { @@ -60,7 +67,13 @@ export async function createAgentAndResume( } } - return prepareAgent(agent, gates, moderation, onSessionChange) + return prepareAgent( + agent, + gates, + moderation, + onSessionChange, + setPersistSessionHandler, + ) } export async function createAgentAndLogin( @@ -80,6 +93,7 @@ export async function createAgentAndLogin( did: string, event: AtpSessionEvent, ) => void, + setPersistSessionHandler: SetPersistSessionHandler, ) { const agent = new BskyAgent({service}) await agent.login({identifier, password, authFactorToken}) @@ -87,7 +101,13 @@ export async function createAgentAndLogin( const account = agentToSessionAccountOrThrow(agent) const gates = tryFetchGates(account.did, 'prefer-fresh-gates') const moderation = configureModerationForAccount(agent, account) - return prepareAgent(agent, moderation, gates, onSessionChange) + return prepareAgent( + agent, + moderation, + gates, + onSessionChange, + setPersistSessionHandler, + ) } export async function createAgentAndCreateAccount( @@ -115,6 +135,7 @@ export async function createAgentAndCreateAccount( did: string, event: AtpSessionEvent, ) => void, + setPersistSessionHandler: SetPersistSessionHandler, ) { const agent = new BskyAgent({service}) await agent.createAccount({ @@ -174,7 +195,13 @@ export async function createAgentAndCreateAccount( logger.error(e, {context: `session: failed snoozeEmailConfirmationPrompt`}) } - return prepareAgent(agent, gates, moderation, onSessionChange) + return prepareAgent( + agent, + gates, + moderation, + onSessionChange, + setPersistSessionHandler, + ) } async function prepareAgent( @@ -187,13 +214,14 @@ async function prepareAgent( did: string, event: AtpSessionEvent, ) => void, + setPersistSessionHandler: (cb: AtpPersistSessionHandler) => void, ) { // There's nothing else left to do, so block on them here. await Promise.all([gates, moderation]) // Now the agent is ready. const account = agentToSessionAccountOrThrow(agent) - agent.setPersistSessionHandler(event => { + setPersistSessionHandler(event => { onSessionChange(agent, account.did, event) if (event !== 'create' && event !== 'update') { addSessionErrorLog(account.did, event) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 09fcf86642..4f01f71654 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -1,5 +1,9 @@ import React from 'react' -import {AtpSessionEvent, BskyAgent} from '@atproto/api' +import { + AtpPersistSessionHandler, + AtpSessionEvent, + BskyAgent, +} from '@atproto/api' import {track} from '#/lib/analytics/analytics' import {logEvent} from '#/lib/statsig/statsig' @@ -47,6 +51,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return initialState }) + const persistSessionHandler = React.useRef< + AtpPersistSessionHandler | undefined + >(undefined) + const setPersistSessionHandler = ( + newHandler: AtpPersistSessionHandler | undefined, + ) => { + persistSessionHandler.current = newHandler + } + const onAgentSessionChange = React.useCallback( (agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away. @@ -73,6 +86,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const {agent, account} = await createAgentAndCreateAccount( params, onAgentSessionChange, + setPersistSessionHandler, ) if (signal.aborted) { @@ -97,6 +111,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const {agent, account} = await createAgentAndLogin( params, onAgentSessionChange, + setPersistSessionHandler, ) if (signal.aborted) { @@ -138,6 +153,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const {agent, account} = await createAgentAndResume( storedAccount, onAgentSessionChange, + setPersistSessionHandler, ) if (signal.aborted) { @@ -202,7 +218,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } else { const agent = state.currentAgentState.agent as BskyAgent const prevSession = agent.session - agent.session = sessionAccountToSession(syncedAccount) + agent.sessionManager.session = sessionAccountToSession(syncedAccount) addSessionDebugLog({ type: 'agent:patch', agent, @@ -249,8 +265,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent}) // We never reuse agents so let's fully neutralize the previous one. // This ensures it won't try to consume any refresh tokens. - prevAgent.session = undefined - prevAgent.setPersistSessionHandler(undefined) + prevAgent.sessionManager.session = undefined + setPersistSessionHandler(undefined) } }, [agent]) diff --git a/src/state/session/logging.ts b/src/state/session/logging.ts index b57f1fa0b0..7e1df500be 100644 --- a/src/state/session/logging.ts +++ b/src/state/session/logging.ts @@ -56,7 +56,7 @@ type Log = type: 'agent:patch' agent: object prevSession: AtpSessionData | undefined - nextSession: AtpSessionData + nextSession: AtpSessionData | undefined } export function wrapSessionReducerForLogging(reducer: Reducer): Reducer { diff --git a/yarn.lock b/yarn.lock index ba1227f30b..cd0508d6a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,39 +34,65 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@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== +"@atproto-labs/fetch-node@0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@atproto-labs/fetch-node/-/fetch-node-0.1.0.tgz#692666d57ec24a7ba0813077a303baccf26108e0" + integrity sha512-DUHgaGw8LBqiGg51pUDuWK/alMcmNbpcK7ALzlF2Gw//TNLTsgrj0qY9aEtK+np9rEC+x/o3bN4SGnuQEpgqIg== + dependencies: + "@atproto-labs/fetch" "0.1.0" + "@atproto-labs/pipe" "0.1.0" + ipaddr.js "^2.1.0" + psl "^1.9.0" + undici "^6.14.1" + +"@atproto-labs/fetch@0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@atproto-labs/fetch/-/fetch-0.1.0.tgz#50a46943fd2f321dd748de28c73ba7cbfa493132" + integrity sha512-uirja+uA/C4HNk7vayM+AJqsccxQn2wVziUHxbsjJGt/K6Q8ZOKDaEX2+GrcXvpUVcqUKh+94JFjuzH+CAEUlg== + dependencies: + "@atproto-labs/pipe" "0.1.0" + optionalDependencies: + zod "^3.23.8" + +"@atproto-labs/pipe@0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@atproto-labs/pipe/-/pipe-0.1.0.tgz#c8d86923b6d8e900d39efe6fdcdf0d897c434086" + integrity sha512-ghOqHFyJlQVFPESzlVHjKroP0tPzbmG5Jms0dNI9yLDEfL8xp4OFPWLX4f6T8mRq69wWs4nIDM3sSsFbFqLa1w== + +"@atproto-labs/simple-store-memory@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.1.tgz#54526a1f8ec978822be9fad75106ad8b78500dd3" + integrity sha512-PCRqhnZ8NBNBvLku53O56T0lsVOtclfIrQU/rwLCc4+p45/SBPrRYNBi6YFq5rxZbK6Njos9MCmILV/KLQxrWA== + dependencies: + "@atproto-labs/simple-store" "0.1.1" + lru-cache "^10.2.0" + +"@atproto-labs/simple-store@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106" + integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg== + +"@atproto/api@0.13.0", "@atproto/api@^0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8" + integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA== dependencies: "@atproto/common-web" "^0.3.0" - "@atproto/lexicon" "^0.4.0" + "@atproto/lexicon" "^0.4.1" "@atproto/syntax" "^0.3.0" - "@atproto/xrpc" "^0.5.0" + "@atproto/xrpc" "^0.6.0" await-lock "^2.2.2" multiformats "^9.9.0" tlds "^1.234.0" -"@atproto/api@^0.12.3": - version "0.12.3" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.3.tgz#5b7b1c7d4210ee9315961504900c8409395cbb17" - integrity sha512-y/kGpIEo+mKGQ7VOphpqCAigTI0LZRmDThNChTfSzDKm9TzEobwiw0zUID0Yw6ot1iLLFx3nKURmuZAYlEuobw== +"@atproto/aws@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.2.tgz#703e5e06f288bcf61c6d99a990738f1e7299e653" + integrity sha512-j7eR7+sQumFsc66/5xyCDez9JtR6dlZc+fOdwdh85nCJD4zmQyU4r1CKrA48wQ3tkzze+ASEb1SgODuIQmIugA== dependencies: - "@atproto/common-web" "^0.3.0" - "@atproto/lexicon" "^0.4.0" - "@atproto/syntax" "^0.3.0" - "@atproto/xrpc" "^0.5.0" - multiformats "^9.9.0" - tlds "^1.234.0" - -"@atproto/aws@^0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.0.tgz#17f3faf744824457cabd62f87be8bf08cacf8029" - integrity sha512-F09SHiC9CX3ydfrvYZbkpfES48UGCQNnznNVgJ3QyKSN8ON+BoWmGCpAFtn3AWeEoU0w9h0hypNvUm5nORv+5g== - dependencies: - "@atproto/common" "^0.4.0" + "@atproto/common" "^0.4.1" "@atproto/crypto" "^0.4.0" - "@atproto/repo" "^0.4.0" + "@atproto/repo" "^0.4.2" "@aws-sdk/client-cloudfront" "^3.261.0" "@aws-sdk/client-kms" "^3.196.0" "@aws-sdk/client-s3" "^3.224.0" @@ -76,19 +102,19 @@ multiformats "^9.9.0" uint8arrays "3.0.0" -"@atproto/bsky@^0.0.45": - version "0.0.45" - resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.45.tgz#c3083d8038fe8c5ff921d9bcb0b5a043cc840827" - integrity sha512-osWeigdYzQH2vZki+eszCR8ta9zdUB4om79aFmnE+zvxw7HFduwAAbcHf6kmmiLCfaOWvCsYb1wS2i3IC66TAg== +"@atproto/bsky@^0.0.74": + version "0.0.74" + resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.74.tgz#b735af6ded16778604378710a2e871350c29570a" + integrity sha512-vyukmlBamoET0sZnDMOeTGAkQNV7KbHg65uIQ6OX4/QGynyaQP8SvSF0OsEBzBqOraxV1w9WT8AZrUbyl3uvIg== dependencies: - "@atproto/api" "^0.12.3" - "@atproto/common" "^0.4.0" + "@atproto/api" "^0.13.0" + "@atproto/common" "^0.4.1" "@atproto/crypto" "^0.4.0" "@atproto/identity" "^0.4.0" - "@atproto/lexicon" "^0.4.0" - "@atproto/repo" "^0.4.0" + "@atproto/lexicon" "^0.4.1" + "@atproto/repo" "^0.4.2" "@atproto/syntax" "^0.3.0" - "@atproto/xrpc-server" "^0.5.1" + "@atproto/xrpc-server" "^0.6.1" "@bufbuild/protobuf" "^1.5.0" "@connectrpc/connect" "^1.1.4" "@connectrpc/connect-express" "^1.1.4" @@ -105,19 +131,20 @@ multiformats "^9.9.0" p-queue "^6.6.2" pg "^8.10.0" - pino "^8.15.0" + pino "^8.21.0" pino-http "^8.2.1" sharp "^0.32.6" + statsig-node "^5.23.1" structured-headers "^1.0.1" typed-emitter "^2.1.0" uint8arrays "3.0.0" -"@atproto/bsync@^0.0.3": - version "0.0.3" - resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.3.tgz#2b0b8ef3686cf177846a80088317f2e89d1bf88f" - integrity sha512-tJRwNgXzfNV57lzgWPvjtb1OMlMJH9SpsMeYhIii16zcaFUWwsb474BicKpkGRT+iCvtYzBT6gWlZE2Ijnhf7w== +"@atproto/bsync@^0.0.5": + version "0.0.5" + resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.5.tgz#bf2fa45e4595fda12addcd6784314e4dbe409046" + integrity sha512-xCCMHy14y4tQoXiGrfd0XjSnc4q7I9bUNqju9E8jrP95QTDedH1FQgybStbUIbHt0eEqY5v9E7iZBH3n7Kiz7A== dependencies: - "@atproto/common" "^0.4.0" + "@atproto/common" "^0.4.1" "@atproto/syntax" "^0.3.0" "@bufbuild/protobuf" "^1.5.0" "@connectrpc/connect" "^1.1.4" @@ -158,17 +185,17 @@ pino "^8.6.1" zod "^3.14.2" -"@atproto/common@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.0.tgz#d77696c7eb545426df727837d9ee333b429fe7ef" - integrity sha512-yOXuPlCjT/OK9j+neIGYn9wkxx/AlxQSucysAF0xgwu0Ji8jAtKBf9Jv6R5ObYAjAD/kVUvEYumle+Yq/R9/7g== +"@atproto/common@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.1.tgz#ca6fce47001ce8d031acd3fb4942fbfd81f72c43" + integrity sha512-uL7kQIcBTbvkBDNfxMXL6lBH4fO2DQpHd2BryJxMtbw/4iEPKe9xBYApwECHhEIk9+zhhpTRZ15FJ3gxTXN82Q== dependencies: "@atproto/common-web" "^0.3.0" "@ipld/dag-cbor" "^7.0.3" cbor-x "^1.5.1" iso-datestring-validator "^2.2.2" multiformats "^9.9.0" - pino "^8.15.0" + pino "^8.21.0" "@atproto/crypto@0.1.0": version "0.1.0" @@ -190,22 +217,22 @@ "@noble/hashes" "^1.3.1" uint8arrays "3.0.0" -"@atproto/dev-env@^0.3.5": - version "0.3.5" - resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.5.tgz#cd13313dbc52131731d039a1d22808ee8193505d" - integrity sha512-dqRNihzX1xIHbWPHmfYsliUUXyZn5FFhCeButrGie5soQmHA4okQJTB1XWDly3mdHLjUM90g+5zjRSAKoui77Q== +"@atproto/dev-env@^0.3.39": + version "0.3.39" + resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.39.tgz#f498f087d4da43d5f86805c07d5f2b781e60fd6f" + integrity sha512-rIeUO99DL8/gRKYEAkAFuTn77y8letEbKMXnfpsVX2YHD89VRdDyMxkYzRu2+31UjtGv62I+qTLLKQS4EcFItA== dependencies: - "@atproto/api" "^0.12.3" - "@atproto/bsky" "^0.0.45" - "@atproto/bsync" "^0.0.3" + "@atproto/api" "^0.13.0" + "@atproto/bsky" "^0.0.74" + "@atproto/bsync" "^0.0.5" "@atproto/common-web" "^0.3.0" "@atproto/crypto" "^0.4.0" "@atproto/identity" "^0.4.0" - "@atproto/lexicon" "^0.4.0" - "@atproto/ozone" "^0.1.7" - "@atproto/pds" "^0.4.14" + "@atproto/lexicon" "^0.4.1" + "@atproto/ozone" "^0.1.36" + "@atproto/pds" "^0.4.48" "@atproto/syntax" "^0.3.0" - "@atproto/xrpc-server" "^0.5.1" + "@atproto/xrpc-server" "^0.6.1" "@did-plc/lib" "^0.0.1" "@did-plc/server" "^0.0.1" axios "^0.27.2" @@ -224,30 +251,79 @@ "@atproto/crypto" "^0.4.0" axios "^0.27.2" -"@atproto/lexicon@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576" - integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ== +"@atproto/jwk-jose@0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.2.tgz#236eadb740b498689d9a912d1254aa9ff58890a1" + integrity sha512-lDwc/6lLn2aZ/JpyyggyjLFsJPMntrVzryyGUx5aNpuTS8SIuc4Ky0REhxqfLopQXJJZCuRRjagHG3uP05/moQ== + dependencies: + "@atproto/jwk" "0.1.1" + jose "^5.2.0" + +"@atproto/jwk@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.1.1.tgz#15bcad4a1778eeb20c82108e0ec55fef45cd07b6" + integrity sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og== + dependencies: + multiformats "^9.9.0" + zod "^3.23.8" + +"@atproto/lexicon@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.1.tgz#19155210570a2fafbcc7d4f655d9b813948e72a0" + integrity sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/syntax" "^0.3.0" iso-datestring-validator "^2.2.2" multiformats "^9.9.0" - zod "^3.21.4" + zod "^3.23.8" -"@atproto/ozone@^0.1.7": - version "0.1.7" - resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.7.tgz#248d88e1acfe56936651754975472d03d047d689" - integrity sha512-vvaV0MFynOzZJcL8m8mEW21o1FFIkP+wHTXEC9LJrL3h03+PMaby8Ujmif6WX5eikhfxvr9xsU/Jxbi/iValuQ== +"@atproto/oauth-provider@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.1.2.tgz#a576a4c7795c7938a994e76192c19a2e73ffcddf" + integrity sha512-z1YKK0XLDfSDtLP5ntPCviEtajvUHbI4TwzYQ5X9CAL9PoXjqhQg0U/csg1wGDs8qkbphF9gni9M2stlpH7H0g== dependencies: - "@atproto/api" "^0.12.3" - "@atproto/common" "^0.4.0" + "@atproto-labs/fetch" "0.1.0" + "@atproto-labs/fetch-node" "0.1.0" + "@atproto-labs/pipe" "0.1.0" + "@atproto-labs/simple-store" "0.1.1" + "@atproto-labs/simple-store-memory" "0.1.1" + "@atproto/jwk" "0.1.1" + "@atproto/jwk-jose" "0.1.2" + "@atproto/oauth-types" "0.1.2" + "@hapi/accept" "^6.0.3" + "@hapi/bourne" "^3.0.0" + cookie "^0.6.0" + http-errors "^2.0.0" + jose "^5.2.0" + oidc-token-hash "^5.0.3" + psl "^1.9.0" + zod "^3.23.8" + optionalDependencies: + ioredis "^5.3.2" + keygrip "^1.1.0" + +"@atproto/oauth-types@0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.1.2.tgz#d6c497c8e5f88f1875c630adde4ed9c5d8a8b4f4" + integrity sha512-yySPPTLxteFJ3O3xVWEhvBFx7rczgo4LK2nQNeqAPMZdYd5dpgvuZZ88nQQge074BfuOc0MWTnr0kPdxQMjjPw== + dependencies: + "@atproto/jwk" "0.1.1" + zod "^3.23.8" + +"@atproto/ozone@^0.1.36": + version "0.1.36" + resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.36.tgz#6a1a71fdff3ff486c5951a9e491e954b51703d53" + integrity sha512-BQThLU5RFG+/bZli/fj5YrFU8jW5rkium7aplfJX2eHkV6huJnBU5DcgracjH2paPGC5L/zjYtibz5spqatKAg== + dependencies: + "@atproto/api" "^0.13.0" + "@atproto/common" "^0.4.1" "@atproto/crypto" "^0.4.0" "@atproto/identity" "^0.4.0" - "@atproto/lexicon" "^0.4.0" + "@atproto/lexicon" "^0.4.1" "@atproto/syntax" "^0.3.0" - "@atproto/xrpc" "^0.5.0" - "@atproto/xrpc-server" "^0.5.1" + "@atproto/xrpc" "^0.6.0" + "@atproto/xrpc-server" "^0.6.1" "@did-plc/lib" "^0.0.1" axios "^1.6.7" compression "^1.7.4" @@ -255,30 +331,34 @@ express "^4.17.2" http-terminator "^3.2.0" kysely "^0.22.0" + lande "^1.0.10" multiformats "^9.9.0" p-queue "^6.6.2" pg "^8.10.0" pino-http "^8.2.1" + structured-headers "^1.0.1" typed-emitter "^2.1.0" uint8arrays "3.0.0" -"@atproto/pds@^0.4.14": - version "0.4.14" - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.14.tgz#5b55ef307323bda712f2ddaba5c1fff7740ed91b" - integrity sha512-rqVcvtw5oMuuJIpWZbSSTSx19+JaZyUcg9OEjdlUmyEpToRN88zTEQySEksymrrLQkW/LPRyWGd7WthbGEuEfQ== +"@atproto/pds@^0.4.48": + version "0.4.48" + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.48.tgz#34f29846a0585f5cc33f1685eb75ad730b7dcb9f" + integrity sha512-B5FpmECkGtA0EyhiB5rfhmQArmGekqqyzFnPlNpO5vOUrTTVKc9mgGfHLVJtrnwDUfGAuIgpigqZ8HgwS0DnMA== dependencies: - "@atproto/api" "^0.12.3" - "@atproto/aws" "^0.2.0" - "@atproto/common" "^0.4.0" + "@atproto-labs/fetch-node" "0.1.0" + "@atproto/api" "^0.13.0" + "@atproto/aws" "^0.2.2" + "@atproto/common" "^0.4.1" "@atproto/crypto" "^0.4.0" "@atproto/identity" "^0.4.0" - "@atproto/lexicon" "^0.4.0" - "@atproto/repo" "^0.4.0" + "@atproto/lexicon" "^0.4.1" + "@atproto/oauth-provider" "^0.1.2" + "@atproto/repo" "^0.4.2" "@atproto/syntax" "^0.3.0" - "@atproto/xrpc" "^0.5.0" - "@atproto/xrpc-server" "^0.5.1" + "@atproto/xrpc" "^0.6.0" + "@atproto/xrpc-server" "^0.6.1" "@did-plc/lib" "^0.0.4" - better-sqlite3 "^9.4.0" + better-sqlite3 "^10.0.0" bytes "^3.1.2" compression "^1.7.4" cors "^2.8.5" @@ -297,41 +377,42 @@ nodemailer "^6.8.0" nodemailer-html-to-text "^3.2.0" p-queue "^6.6.2" - pino "^8.15.0" + pino "^8.21.0" pino-http "^8.2.1" sharp "^0.32.6" typed-emitter "^2.1.0" uint8arrays "3.0.0" - zod "^3.21.4" + zod "^3.23.8" -"@atproto/repo@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.4.0.tgz#e5d3195a8e4233c9bf060737b18ddee905af2d9a" - integrity sha512-LB0DF/D8r8hB+qiGB0sWZuq7TSJYbWel+t572aCrLeCOmbRgnLkGPLUTOOUvLFYv8xz1BPZTbI8hy/vcUV79VA== +"@atproto/repo@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.4.2.tgz#311eef52ef5df0b6f969fb4b329935a32db05313" + integrity sha512-6hEGA3BmasPCoBGaIN/jKAjKJidCf+z8exkx/77V3WB7TboucSLHn/8gg+Xf03U7bJd6mn3F0YmPaRfJwqIT8w== dependencies: - "@atproto/common" "^0.4.0" + "@atproto/common" "^0.4.1" "@atproto/common-web" "^0.3.0" "@atproto/crypto" "^0.4.0" - "@atproto/lexicon" "^0.4.0" + "@atproto/lexicon" "^0.4.1" "@ipld/car" "^3.2.3" "@ipld/dag-cbor" "^7.0.0" multiformats "^9.9.0" uint8arrays "3.0.0" - zod "^3.21.4" + zod "^3.23.8" "@atproto/syntax@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3" integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA== -"@atproto/xrpc-server@^0.5.1": - version "0.5.1" - resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.5.1.tgz#f63c86ba60bd5b9c5a641ea57191ff83d9db41fd" - integrity sha512-SXU6dscVe5iYxPeV79QIFs/yEEu7LLOzyHGoHG1kSNO6DjwxXTdcWOc8GSYGV6H+7VycOoPZPkyD9q4teJlj/w== +"@atproto/xrpc-server@^0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.6.1.tgz#c8c75065ab6bc1a7f5c121b558acb5213f2afda6" + integrity sha512-Qm0aJC1LbYYHaRGWoh0D2iG48VwRha1T1NEP/D5UkD4GzfjT8m5PDiZBtcyspJD/BEC7UYX9/BhMYCoZLQMYcA== dependencies: - "@atproto/common" "^0.4.0" + "@atproto/common" "^0.4.1" "@atproto/crypto" "^0.4.0" - "@atproto/lexicon" "^0.4.0" + "@atproto/lexicon" "^0.4.1" + "@atproto/xrpc" "^0.6.0" cbor-x "^1.5.1" express "^4.17.2" http-errors "^2.0.0" @@ -339,15 +420,15 @@ rate-limiter-flexible "^2.4.1" uint8arrays "3.0.0" ws "^8.12.0" - zod "^3.21.4" + zod "^3.23.8" -"@atproto/xrpc@^0.5.0": - version "0.5.0" - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032" - integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog== +"@atproto/xrpc@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c" + integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ== dependencies: - "@atproto/lexicon" "^0.4.0" - zod "^3.21.4" + "@atproto/lexicon" "^0.4.1" + zod "^3.23.8" "@aws-crypto/crc32@3.0.0": version "3.0.0" @@ -4001,6 +4082,31 @@ resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== +"@hapi/accept@^6.0.3": + version "6.0.3" + resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-6.0.3.tgz#eef0800a4f89cd969da8e5d0311dc877c37279ab" + integrity sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw== + dependencies: + "@hapi/boom" "^10.0.1" + "@hapi/hoek" "^11.0.2" + +"@hapi/boom@^10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-10.0.1.tgz#ebb14688275ae150aa6af788dbe482e6a6062685" + integrity sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA== + dependencies: + "@hapi/hoek" "^11.0.2" + +"@hapi/bourne@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7" + integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w== + +"@hapi/hoek@^11.0.2": + version "11.0.4" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-11.0.4.tgz#42a7f244fd3dd777792bfb74b8c6340ae9182f37" + integrity sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ== + "@hapi/hoek@^9.0.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" @@ -9453,10 +9559,10 @@ better-opn@~3.0.2: dependencies: open "^8.0.4" -better-sqlite3@^9.4.0: - version "9.4.5" - resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-9.4.5.tgz#1d3422443a9924637cb06cc3ccc941b2ae932c65" - integrity sha512-uFVyoyZR9BNcjSca+cp3MWCv6upAv+tbMC4SWM51NIMhoQOm4tjIkyxFO/ZsYdGAF61WJBgdzyJcz4OokJi0gQ== +better-sqlite3@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-10.1.0.tgz#8dc07e496fc014a7cd2211f79e591f6ba92838e8" + integrity sha512-hqpHJaCfKEZFaAWdMh6crdzRWyzQzfP6Ih8TYI0vFn01a6ZTDSbJIMXN+6AMBaBOh99DzUy8l3PsV9R3qnJDng== dependencies: bindings "^1.5.0" prebuild-install "^7.1.1" @@ -10305,6 +10411,11 @@ cookie@0.5.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +cookie@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== + copy-webpack-plugin@^10.2.0: version "10.2.4" resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz#6c854be3fdaae22025da34b9112ccf81c63308fe" @@ -13779,6 +13890,11 @@ ip-regex@^2.1.0: resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" integrity sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw== +ip3country@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ip3country/-/ip3country-5.0.0.tgz#f1394b050c51ba9c10cc691c8eb240bba3d7177a" + integrity sha512-lcFLMFU4eO1Z7tIpbVFZkaZ5ltqpeaRx7L9NsAbA9uA7/O/rj3RF8+evE5gDitooaTTIqjdzZrenFO/OOxQ2ew== + ipaddr.js@1.9.1, ipaddr.js@^1.9.0: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" @@ -13789,6 +13905,11 @@ ipaddr.js@^2.0.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.1.0.tgz#2119bc447ff8c257753b196fc5f1ce08a4cdf39f" integrity sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ== +ipaddr.js@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" + integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== + is-arguments@^1.0.4: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -15351,6 +15472,11 @@ jose@^5.0.1: resolved "https://registry.yarnpkg.com/jose/-/jose-5.1.3.tgz#303959d85c51b5cb14725f930270b72be56abdca" integrity sha512-GPExOkcMsCLBTi1YetY2LmkoY559fss0+0KVa6kOfb2YFe84nAM7Nm/XzuZozah4iHgmBGrCOHL5/cy670SBRw== +jose@^5.2.0: + version "5.6.3" + resolved "https://registry.yarnpkg.com/jose/-/jose-5.6.3.tgz#415688bc84875461c86dfe271ea6029112a23e27" + integrity sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g== + js-base64@^3.7.2: version "3.7.5" resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.5.tgz#21e24cf6b886f76d6f5f165bfcd69cc55b9e3fca" @@ -15603,6 +15729,13 @@ key-encoder@^2.0.3: bn.js "^4.11.8" elliptic "^6.4.1" +keygrip@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" + integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ== + dependencies: + tsscmp "1.0.6" + kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" @@ -16776,6 +16909,13 @@ node-fetch@^2.2.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.12, nod dependencies: whatwg-url "^5.0.0" +node-fetch@^2.6.13: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-forge@^1, node-forge@^1.2.1, node-forge@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" @@ -17024,6 +17164,11 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== +oidc-token-hash@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz#9a229f0a1ce9d4fc89bcaee5478c97a889e7b7b6" + integrity sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw== + on-exit-leak-free@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz#5c703c968f7e7f851885f6459bf8a8a57edc9cc4" @@ -17567,18 +17712,18 @@ pinkie@^2.0.0: resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" integrity sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg== -pino-abstract-transport@v1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.0.0.tgz#cc0d6955fffcadb91b7b49ef220a6cc111d48bb3" - integrity sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA== +pino-abstract-transport@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz#97f9f2631931e242da531b5c66d3079c12c9d1b5" + integrity sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q== dependencies: readable-stream "^4.0.0" split2 "^4.0.0" -pino-abstract-transport@v1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.1.0.tgz#083d98f966262164504afb989bccd05f665937a8" - integrity sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA== +pino-abstract-transport@v1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.0.0.tgz#cc0d6955fffcadb91b7b49ef220a6cc111d48bb3" + integrity sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA== dependencies: readable-stream "^4.0.0" split2 "^4.0.0" @@ -17615,22 +17760,22 @@ pino@^8.0.0, pino@^8.11.0, pino@^8.6.1: sonic-boom "^3.1.0" thread-stream "^2.0.0" -pino@^8.15.0: - version "8.15.1" - resolved "https://registry.yarnpkg.com/pino/-/pino-8.15.1.tgz#04b815ff7aa4e46b1bbab88d8010aaa2b17eaba4" - integrity sha512-Cp4QzUQrvWCRJaQ8Lzv0mJzXVk4z2jlq8JNKMGaixC2Pz5L4l2p95TkuRvYbrEbe85NQsDKrAd4zalf7Ml6WiA== +pino@^8.21.0: + version "8.21.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-8.21.0.tgz#e1207f3675a2722940d62da79a7a55a98409f00d" + integrity sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q== dependencies: atomic-sleep "^1.0.0" fast-redact "^3.1.1" on-exit-leak-free "^2.1.0" - pino-abstract-transport v1.1.0 + pino-abstract-transport "^1.2.0" pino-std-serializers "^6.0.0" - process-warning "^2.0.0" + process-warning "^3.0.0" quick-format-unescaped "^4.0.3" real-require "^0.2.0" safe-stable-stringify "^2.3.1" - sonic-boom "^3.1.0" - thread-stream "^2.0.0" + sonic-boom "^3.7.0" + thread-stream "^2.6.0" pirates@^4.0.1, pirates@^4.0.4, pirates@^4.0.5: version "4.0.6" @@ -18392,6 +18537,11 @@ process-warning@^2.0.0: resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-2.2.0.tgz#008ec76b579820a8e5c35d81960525ca64feb626" integrity sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg== +process-warning@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-3.0.0.tgz#96e5b88884187a1dce6f5c3166d611132058710b" + integrity sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ== + process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" @@ -20229,6 +20379,13 @@ sonic-boom@^3.1.0: dependencies: atomic-sleep "^1.0.0" +sonic-boom@^3.7.0: + version "3.8.1" + resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-3.8.1.tgz#d5ba8c4e26d6176c9a1d14d549d9ff579a163422" + integrity sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg== + dependencies: + atomic-sleep "^1.0.0" + source-list-map@^2.0.0, source-list-map@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" @@ -20419,6 +20576,16 @@ statsig-js@4.45.1: js-sha256 "^0.10.1" uuid "^8.3.2" +statsig-node@^5.23.1: + version "5.25.1" + resolved "https://registry.yarnpkg.com/statsig-node/-/statsig-node-5.25.1.tgz#6d8ea9ecaad6c09250e5ff7d33eda9fd0f9c05f4" + integrity sha512-K8+1psxFVdFr5LyXwDotJqBl7uKt8vbZO2e/9zzbLI4yDOuLDoItG5Ju5QAR0oUfEdEAANOzwV2yA052Wrc/Xw== + dependencies: + ip3country "^5.0.0" + node-fetch "^2.6.13" + ua-parser-js "^1.0.2" + uuid "^8.3.2" + statsig-react-native-expo@^4.6.1: version "4.6.1" resolved "https://registry.yarnpkg.com/statsig-react-native-expo/-/statsig-react-native-expo-4.6.1.tgz#0bdf49fee7112f7f28bff2405f4ba0c1727bb3d6" @@ -21052,6 +21219,13 @@ thread-stream@^2.0.0: dependencies: real-require "^0.2.0" +thread-stream@^2.6.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-2.7.0.tgz#d8a8e1b3fd538a6cca8ce69dbe5d3d097b601e11" + integrity sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw== + dependencies: + real-require "^0.2.0" + throat@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" @@ -21251,6 +21425,11 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4 resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== +tsscmp@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -21388,6 +21567,11 @@ ua-parser-js@^0.7.33: resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.35.tgz#8bda4827be4f0b1dda91699a29499575a1f1d307" integrity sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g== +ua-parser-js@^1.0.2: + version "1.0.38" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.38.tgz#66bb0c4c0e322fe48edfe6d446df6042e62f25e2" + integrity sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ== + ua-parser-js@^1.0.35: version "1.0.35" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.35.tgz#c4ef44343bc3db0a3cbefdf21822f1b1fc1ab011" @@ -21432,6 +21616,11 @@ undici@^5.28.2: dependencies: "@fastify/busboy" "^2.0.0" +undici@^6.14.1: + version "6.19.5" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.19.5.tgz#5829101361b583b53206e81579f4df71c56d6be8" + integrity sha512-LryC15SWzqQsREHIOUybavaIHF5IoL0dJ9aWWxL/PgT1KfqAW5225FZpDUFlt9xiDMS2/S7DOKhFWA7RLksWdg== + unfetch@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-3.1.2.tgz#dc271ef77a2800768f7b459673c5604b5101ef77" @@ -22603,7 +22792,7 @@ zod-validation-error@^3.0.3: resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.3.0.tgz#2cfe81b62d044e0453d1aa3ae7c32a2f36dde9af" integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw== -zod@3.23.8, zod@^3.14.2, zod@^3.20.2, zod@^3.21.4, zod@^3.22.4: +zod@3.23.8, zod@^3.14.2, zod@^3.20.2, zod@^3.21.4, zod@^3.22.4, zod@^3.23.8: version "3.23.8" resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== From 134fcd35d84788659effd3a9d0b9e8952b85e0da Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 12 Aug 2024 14:58:41 -0700 Subject: [PATCH 48/67] [Video] Invert usage of `setAudioActive` (#4924) --- src/App.native.tsx | 2 +- .../post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/App.native.tsx b/src/App.native.tsx index 8e7c53b93b..bce439a717 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -159,7 +159,7 @@ function App() { React.useEffect(() => { PlatformInfo.setAudioCategory(AudioCategory.Ambient) - PlatformInfo.setAudioActive(true) + PlatformInfo.setAudioActive(false) initPersistedState().then(() => setReady(true)) }, []) diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index 0b48edf793..5722ba73d5 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -60,12 +60,12 @@ export function VideoEmbedInnerNative() { nativeControls={true} onEnterFullscreen={() => { PlatformInfo.setAudioCategory(AudioCategory.Playback) - PlatformInfo.setAudioActive(false) + PlatformInfo.setAudioActive(true) player.muted = false }} onExitFullscreen={() => { PlatformInfo.setAudioCategory(AudioCategory.Ambient) - PlatformInfo.setAudioActive(true) + PlatformInfo.setAudioActive(false) player.muted = true if (!player.playing) player.play() }} From 99d1a881f2f5c16dddfc10550b39e379690c8135 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 12 Aug 2024 16:49:17 -0700 Subject: [PATCH 49/67] [Video] Fix crash when switching tabs (#4925) --- .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index 5722ba73d5..5cbe018722 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -23,29 +23,14 @@ export function VideoEmbedInnerNative() { const ref = useRef(null) const isScreenFocused = useIsFocused() const isAppFocused = useAppState() - const prevFocusedRef = useRef(isAppFocused) - // resume video when coming back from background useEffect(() => { - if (isAppFocused !== prevFocusedRef.current) { - prevFocusedRef.current = isAppFocused - if (isAppFocused === 'active') { - player.play() - } - } - }, [isAppFocused, player]) - - // pause the video when the screen is not focused - useEffect(() => { - if (!isScreenFocused) { - let wasPlaying = player.playing + if (isAppFocused === 'active' && isScreenFocused && !player.playing) { + player.play() + } else if (player.playing) { player.pause() - - return () => { - if (wasPlaying) player.play() - } } - }, [isScreenFocused, player]) + }, [isAppFocused, player, isScreenFocused]) const enterFullscreen = useCallback(() => { ref.current?.enterFullscreen() From 3c04d9bd84b2836b3438a659c99cb16009f3af67 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 12 Aug 2024 19:43:06 -0700 Subject: [PATCH 50/67] subclass agent to add setPersistSessionHandler (#4928) Co-authored-by: Dan Abramov --- src/state/session/agent.ts | 118 +++++++++++++++++------------------- src/state/session/index.tsx | 24 ++------ 2 files changed, 60 insertions(+), 82 deletions(-) diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 73be34bb27..ea6af677cf 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -1,9 +1,4 @@ -import { - AtpPersistSessionHandler, - AtpSessionData, - AtpSessionEvent, - BskyAgent, -} from '@atproto/api' +import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api' import {TID} from '@atproto/common-web' import {networkRetry} from '#/lib/async/retry' @@ -25,11 +20,9 @@ import { import {SessionAccount} from './types' import {isSessionExpired, isSignupQueued} from './util' -type SetPersistSessionHandler = (cb: AtpPersistSessionHandler) => void - export function createPublicAgent() { configureModerationForGuest() // Side effect but only relevant for tests - return new BskyAgent({service: PUBLIC_BSKY_SERVICE}) + return new BskyAppAgent({service: PUBLIC_BSKY_SERVICE}) } export async function createAgentAndResume( @@ -39,9 +32,8 @@ export async function createAgentAndResume( did: string, event: AtpSessionEvent, ) => void, - setPersistSessionHandler: SetPersistSessionHandler, ) { - const agent = new BskyAgent({service: storedAccount.service}) + const agent = new BskyAppAgent({service: storedAccount.service}) if (storedAccount.pdsUrl) { agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl) } @@ -67,13 +59,7 @@ export async function createAgentAndResume( } } - return prepareAgent( - agent, - gates, - moderation, - onSessionChange, - setPersistSessionHandler, - ) + return agent.prepare(gates, moderation, onSessionChange) } export async function createAgentAndLogin( @@ -93,21 +79,14 @@ export async function createAgentAndLogin( did: string, event: AtpSessionEvent, ) => void, - setPersistSessionHandler: SetPersistSessionHandler, ) { - const agent = new BskyAgent({service}) + const agent = new BskyAppAgent({service}) await agent.login({identifier, password, authFactorToken}) const account = agentToSessionAccountOrThrow(agent) const gates = tryFetchGates(account.did, 'prefer-fresh-gates') const moderation = configureModerationForAccount(agent, account) - return prepareAgent( - agent, - moderation, - gates, - onSessionChange, - setPersistSessionHandler, - ) + return agent.prepare(gates, moderation, onSessionChange) } export async function createAgentAndCreateAccount( @@ -135,9 +114,8 @@ export async function createAgentAndCreateAccount( did: string, event: AtpSessionEvent, ) => void, - setPersistSessionHandler: SetPersistSessionHandler, ) { - const agent = new BskyAgent({service}) + const agent = new BskyAppAgent({service}) await agent.createAccount({ email, password, @@ -195,39 +173,7 @@ export async function createAgentAndCreateAccount( logger.error(e, {context: `session: failed snoozeEmailConfirmationPrompt`}) } - return prepareAgent( - agent, - gates, - moderation, - onSessionChange, - setPersistSessionHandler, - ) -} - -async function prepareAgent( - agent: BskyAgent, - // Not awaited in the calling code so we can delay blocking on them. - gates: Promise, - moderation: Promise, - onSessionChange: ( - agent: BskyAgent, - did: string, - event: AtpSessionEvent, - ) => void, - setPersistSessionHandler: (cb: AtpPersistSessionHandler) => void, -) { - // There's nothing else left to do, so block on them here. - await Promise.all([gates, moderation]) - - // Now the agent is ready. - const account = agentToSessionAccountOrThrow(agent) - setPersistSessionHandler(event => { - onSessionChange(agent, account.did, event) - if (event !== 'create' && event !== 'update') { - addSessionErrorLog(account.did, event) - } - }) - return {agent, account} + return agent.prepare(gates, moderation, onSessionChange) } export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount { @@ -279,3 +225,51 @@ export function sessionAccountToSession( status: account.status, } } + +// Not exported. Use factories above to create it. +class BskyAppAgent extends BskyAgent { + persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined = + undefined + + constructor({service}: {service: string}) { + super({ + service, + persistSession: (event: AtpSessionEvent) => { + if (this.persistSessionHandler) { + this.persistSessionHandler(event) + } + }, + }) + } + + async prepare( + // Not awaited in the calling code so we can delay blocking on them. + gates: Promise, + moderation: Promise, + onSessionChange: ( + agent: BskyAgent, + did: string, + event: AtpSessionEvent, + ) => void, + ) { + // There's nothing else left to do, so block on them here. + await Promise.all([gates, moderation]) + + // Now the agent is ready. + const account = agentToSessionAccountOrThrow(this) + this.persistSessionHandler = event => { + onSessionChange(this, account.did, event) + if (event !== 'create' && event !== 'update') { + addSessionErrorLog(account.did, event) + } + } + return {account, agent: this} + } + + dispose() { + this.sessionManager.session = undefined + this.persistSessionHandler = undefined + } +} + +export type {BskyAppAgent} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 4f01f71654..ba12f4eaea 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -1,9 +1,5 @@ import React from 'react' -import { - AtpPersistSessionHandler, - AtpSessionEvent, - BskyAgent, -} from '@atproto/api' +import {AtpSessionEvent, BskyAgent} from '@atproto/api' import {track} from '#/lib/analytics/analytics' import {logEvent} from '#/lib/statsig/statsig' @@ -15,6 +11,7 @@ import {IS_DEV} from '#/env' import {emitSessionDropped} from '../events' import { agentToSessionAccount, + BskyAppAgent, createAgentAndCreateAccount, createAgentAndLogin, createAgentAndResume, @@ -51,15 +48,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return initialState }) - const persistSessionHandler = React.useRef< - AtpPersistSessionHandler | undefined - >(undefined) - const setPersistSessionHandler = ( - newHandler: AtpPersistSessionHandler | undefined, - ) => { - persistSessionHandler.current = newHandler - } - const onAgentSessionChange = React.useCallback( (agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away. @@ -86,7 +74,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const {agent, account} = await createAgentAndCreateAccount( params, onAgentSessionChange, - setPersistSessionHandler, ) if (signal.aborted) { @@ -111,7 +98,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const {agent, account} = await createAgentAndLogin( params, onAgentSessionChange, - setPersistSessionHandler, ) if (signal.aborted) { @@ -153,7 +139,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const {agent, account} = await createAgentAndResume( storedAccount, onAgentSessionChange, - setPersistSessionHandler, ) if (signal.aborted) { @@ -255,7 +240,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // @ts-ignore if (IS_DEV && isWeb) window.agent = state.currentAgentState.agent - const agent = state.currentAgentState.agent as BskyAgent + const agent = state.currentAgentState.agent as BskyAppAgent const currentAgentRef = React.useRef(agent) React.useEffect(() => { if (currentAgentRef.current !== agent) { @@ -265,8 +250,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent}) // We never reuse agents so let's fully neutralize the previous one. // This ensures it won't try to consume any refresh tokens. - prevAgent.sessionManager.session = undefined - setPersistSessionHandler(undefined) + prevAgent.dispose() } }, [agent]) From 1fce7a793d6fc67b58f0fccff327930cc0e062b0 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 12 Aug 2024 20:08:51 -0700 Subject: [PATCH 51/67] [Video] Audio duck off main thread (#4926) --- .../PlatformInfo/ExpoPlatformInfoModule.swift | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift index b61066beda..cae4b983d1 100644 --- a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift +++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift @@ -10,19 +10,26 @@ public class ExpoPlatformInfoModule: Module { Function("setAudioCategory") { (audioCategoryString: String) in let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString) - try? AVAudioSession.sharedInstance().setCategory(audioCategory) + + DispatchQueue.global(qos: .background).async { + try? AVAudioSession.sharedInstance().setCategory(audioCategory) + } } Function("setAudioActive") { (active: Bool) in if active { - try? AVAudioSession.sharedInstance().setActive(true) + DispatchQueue.global(qos: .background).async { + try? AVAudioSession.sharedInstance().setActive(true) + } } else { - try? AVAudioSession - .sharedInstance() - .setActive( - false, - options: [.notifyOthersOnDeactivation] - ) + DispatchQueue.global(qos: .background).async { + try? AVAudioSession + .sharedInstance() + .setActive( + false, + options: [.notifyOthersOnDeactivation] + ) + } } } } From 7e11b862e931b5351bd3463d984ab11ee9b46522 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 13 Aug 2024 08:20:39 +0100 Subject: [PATCH 52/67] Remove .withProxy() calls (#4929) --- src/components/ReportDialog/SubmitView.tsx | 35 +++++--------- .../moderation/LabelsOnMeDialog.tsx | 43 ++++++----------- src/lib/statsig/gates.ts | 1 - src/state/feed-feedback.tsx | 46 ++++++------------- 4 files changed, 40 insertions(+), 85 deletions(-) diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx index 7ceece75b6..2def0fa4b4 100644 --- a/src/components/ReportDialog/SubmitView.tsx +++ b/src/components/ReportDialog/SubmitView.tsx @@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react' import {getLabelingServiceTitle} from '#/lib/moderation' import {ReportOption} from '#/lib/moderation/useReportOptions' -import {useGate} from '#/lib/statsig/statsig' import {useAgent} from '#/state/session' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import * as Toast from '#/view/com/util/Toast' @@ -37,7 +36,6 @@ export function SubmitView({ const t = useTheme() const {_} = useLingui() const agent = useAgent() - const gate = useGate() const [details, setDetails] = React.useState('') const [submitting, setSubmitting] = React.useState(false) const [selectedServices, setSelectedServices] = React.useState([ @@ -63,27 +61,17 @@ export function SubmitView({ } const results = await Promise.all( selectedServices.map(did => { - if (gate('session_withproxy_fix')) { - return agent - .createModerationReport(report, { - encoding: 'application/json', - headers: { - 'atproto-proxy': `${did}#atproto_labeler`, - }, - }) - .then( - _ => true, - _ => false, - ) - } else { - return agent - .withProxy('atproto_labeler', did) - .createModerationReport(report) - .then( - _ => true, - _ => false, - ) - } + return agent + .createModerationReport(report, { + encoding: 'application/json', + headers: { + 'atproto-proxy': `${did}#atproto_labeler`, + }, + }) + .then( + _ => true, + _ => false, + ) }), ) @@ -108,7 +96,6 @@ export function SubmitView({ onSubmitComplete, setError, agent, - gate, ]) return ( diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx index b920a0d252..cc11b41017 100644 --- a/src/components/moderation/LabelsOnMeDialog.tsx +++ b/src/components/moderation/LabelsOnMeDialog.tsx @@ -7,7 +7,6 @@ import {useMutation} from '@tanstack/react-query' import {useLabelInfo} from '#/lib/moderation/useLabelInfo' import {makeProfileLink} from '#/lib/routes/links' -import {useGate} from '#/lib/statsig/statsig' import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {useAgent, useSession} from '#/state/session' @@ -204,7 +203,6 @@ function AppealForm({ const [details, setDetails] = React.useState('') const isAccountReport = 'did' in subject const agent = useAgent() - const gate = useGate() const sourceName = labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src @@ -214,35 +212,22 @@ function AppealForm({ const $type = !isAccountReport ? 'com.atproto.repo.strongRef' : 'com.atproto.admin.defs#repoRef' - if (gate('session_withproxy_fix')) { - await agent.createModerationReport( - { - reasonType: ComAtprotoModerationDefs.REASONAPPEAL, - subject: { - $type, - ...subject, - }, - reason: details, + await agent.createModerationReport( + { + reasonType: ComAtprotoModerationDefs.REASONAPPEAL, + subject: { + $type, + ...subject, }, - { - encoding: 'application/json', - headers: { - 'atproto-proxy': `${label.src}#atproto_labeler`, - }, + reason: details, + }, + { + encoding: 'application/json', + headers: { + 'atproto-proxy': `${label.src}#atproto_labeler`, }, - ) - } else { - await agent - .withProxy('atproto_labeler', label.src) - .createModerationReport({ - reasonType: ComAtprotoModerationDefs.REASONAPPEAL, - subject: { - $type, - ...subject, - }, - reason: details, - }) - } + }, + ) }, onError: err => { logger.error('Failed to submit label appeal', {message: err}) diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 5ae6bd5300..492d09e95f 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -3,7 +3,6 @@ export type Gate = | 'debug_show_feedcontext' | 'new_user_guided_tour' | 'onboarding_minimum_interests' - | 'session_withproxy_fix' | 'show_follow_back_label_v2' | 'suggested_feeds_interstitial' | 'video_debug' diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index aab2737e5a..29f328a626 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -1,11 +1,10 @@ import React from 'react' import {AppState, AppStateStatus} from 'react-native' -import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' +import {AppBskyFeedDefs} from '@atproto/api' import throttle from 'lodash.throttle' import {PROD_DEFAULT_FEED} from '#/lib/constants' import {logEvent} from '#/lib/statsig/statsig' -import {useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed' import {getFeedPostSlice} from '#/view/com/posts/Feed' @@ -25,7 +24,6 @@ const stateContext = React.createContext({ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) { const agent = useAgent() - const gate = useGate() const enabled = isDiscoverFeed(feed) && hasSession const queue = React.useRef>(new Set()) const history = React.useRef< @@ -49,34 +47,20 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) { queue.current.clear() // Send to the feed - if (gate('session_withproxy_fix')) { - agent.app.bsky.feed - .sendInteractions( - {interactions}, - { - encoding: 'application/json', - headers: { - // TODO when we start sending to other feeds, we need to grab their DID -prf - 'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg', - }, + agent.app.bsky.feed + .sendInteractions( + {interactions}, + { + encoding: 'application/json', + headers: { + // TODO when we start sending to other feeds, we need to grab their DID -prf + 'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg', }, - ) - .catch((e: any) => { - logger.warn('Failed to send feed interactions', {error: e}) - }) - } else { - const proxyAgent = agent.withProxy( - // @ts-ignore TODO need to update withProxy() to support this key -prf - 'bsky_fg', - // TODO when we start sending to other feeds, we need to grab their DID -prf - 'did:web:discover.bsky.app', - ) as BskyAgent - proxyAgent.app.bsky.feed - .sendInteractions({interactions}) - .catch((e: any) => { - logger.warn('Failed to send feed interactions', {error: e}) - }) - } + }, + ) + .catch((e: any) => { + logger.warn('Failed to send feed interactions', {error: e}) + }) // Send to Statsig if (aggregatedStats.current === null) { @@ -84,7 +68,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) { } sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions) throttledFlushAggregatedStats() - }, [agent, gate, throttledFlushAggregatedStats]) + }, [agent, throttledFlushAggregatedStats]) const sendToFeed = React.useMemo( () => From 57be2ea15b5bea019abf95a590640d688b7a8633 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 13 Aug 2024 18:51:49 +0100 Subject: [PATCH 53/67] Don't kick to login screen on network error (#4911) * Don't kick the user on network errors * Track online status for RQ * Use health endpoint * Update test with new behavior * Only poll while offline * Handle races between the check and network events * Reduce the poll kickoff interval * Don't cache partially fetched pinned feeds This isn't a new issue but it's more prominent with the offline handling. We're currently silently caching pinned infos that failed to fetch. This avoids showing a big spinner on failure but it also kills all feeds which is very confusing. If the request to get feed gens fails, let's fail the whole query. Then it can be retried. --- src/lib/react-query.tsx | 67 ++++++++++++++++++++- src/state/events.ts | 16 +++++ src/state/queries/feed.ts | 3 +- src/state/session/__tests__/session-test.ts | 10 ++- src/state/session/agent.ts | 27 +++++++++ src/state/session/reducer.ts | 8 +-- 6 files changed, 117 insertions(+), 14 deletions(-) diff --git a/src/lib/react-query.tsx b/src/lib/react-query.tsx index be507216aa..5abfccd7f6 100644 --- a/src/lib/react-query.tsx +++ b/src/lib/react-query.tsx @@ -2,18 +2,83 @@ import React, {useRef, useState} from 'react' import {AppState, AppStateStatus} from 'react-native' import AsyncStorage from '@react-native-async-storage/async-storage' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' -import {focusManager, QueryClient} from '@tanstack/react-query' +import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query' import { PersistQueryClientProvider, PersistQueryClientProviderProps, } from '@tanstack/react-query-persist-client' import {isNative} from '#/platform/detection' +import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' // any query keys in this array will be persisted to AsyncStorage export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info' const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot] +async function checkIsOnline(): Promise { + try { + const controller = new AbortController() + setTimeout(() => { + controller.abort() + }, 15e3) + const res = await fetch('https://public.api.bsky.app/xrpc/_health', { + cache: 'no-store', + signal: controller.signal, + }) + const json = await res.json() + if (json.version) { + return true + } else { + return false + } + } catch (e) { + return false + } +} + +let receivedNetworkLost = false +let receivedNetworkConfirmed = false +let isNetworkStateUnclear = false + +listenNetworkLost(() => { + receivedNetworkLost = true + onlineManager.setOnline(false) +}) + +listenNetworkConfirmed(() => { + receivedNetworkConfirmed = true + onlineManager.setOnline(true) +}) + +let checkPromise: Promise | undefined +function checkIsOnlineIfNeeded() { + if (checkPromise) { + return + } + receivedNetworkLost = false + receivedNetworkConfirmed = false + checkPromise = checkIsOnline().then(nextIsOnline => { + checkPromise = undefined + if (nextIsOnline && receivedNetworkLost) { + isNetworkStateUnclear = true + } + if (!nextIsOnline && receivedNetworkConfirmed) { + isNetworkStateUnclear = true + } + if (!isNetworkStateUnclear) { + onlineManager.setOnline(nextIsOnline) + } + }) +} + +setInterval(() => { + if (AppState.currentState === 'active') { + if (!onlineManager.isOnline() || isNetworkStateUnclear) { + checkIsOnlineIfNeeded() + } + } +}, 2000) + focusManager.setEventListener(onFocus => { if (isNative) { const subscription = AppState.addEventListener( diff --git a/src/state/events.ts b/src/state/events.ts index 1384abdeda..dcd36464ec 100644 --- a/src/state/events.ts +++ b/src/state/events.ts @@ -22,6 +22,22 @@ export function listenSessionDropped(fn: () => void): UnlistenFn { return () => emitter.off('session-dropped', fn) } +export function emitNetworkConfirmed() { + emitter.emit('network-confirmed') +} +export function listenNetworkConfirmed(fn: () => void): UnlistenFn { + emitter.on('network-confirmed', fn) + return () => emitter.off('network-confirmed', fn) +} + +export function emitNetworkLost() { + emitter.emit('network-lost') +} +export function listenNetworkLost(fn: () => void): UnlistenFn { + emitter.on('network-lost', fn) + return () => emitter.off('network-lost', fn) +} + export function emitPostCreated() { emitter.emit('post-created') } diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index 2b6751e890..e5ce19a9ad 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -454,7 +454,8 @@ export function usePinnedFeedsInfos() { }), ) - await Promise.allSettled([feedsPromise, ...listsPromises]) + await feedsPromise // Fail the whole query if it fails. + await Promise.allSettled(listsPromises) // Ignore individual failing ones. // order the feeds/lists in the order they were pinned const result: SavedFeedSourceInfo[] = [] diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index 731b66b0e9..cb4c6a35bb 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -1184,7 +1184,7 @@ describe('session', () => { expect(state.currentAgentState.did).toBe('bob-did') }) - it('does soft logout on network error', () => { + it('ignores network errors', () => { let state = getInitialState([]) const agent1 = new BskyAgent({service: 'https://alice.com'}) @@ -1217,11 +1217,9 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(1) - // Network error should reset current user but not reset the tokens. - // TODO: We might want to remove or change this behavior? expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentAgentState.did).toBe('alice-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -1242,9 +1240,9 @@ describe('session', () => { ], "currentAgentState": { "agent": { - "service": "https://public.api.bsky.app/", + "service": "https://alice.com/", }, - "did": undefined, + "did": "alice-did", }, "needsPersist": true, } diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index ea6af677cf..8a48cf95e5 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -12,6 +12,7 @@ import {tryFetchGates} from '#/lib/statsig/statsig' import {getAge} from '#/lib/strings/time' import {logger} from '#/logger' import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' +import {emitNetworkConfirmed, emitNetworkLost} from '../events' import {addSessionErrorLog} from './logging' import { configureModerationForAccount, @@ -227,6 +228,7 @@ export function sessionAccountToSession( } // Not exported. Use factories above to create it. +let realFetch = globalThis.fetch class BskyAppAgent extends BskyAgent { persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined = undefined @@ -234,6 +236,23 @@ class BskyAppAgent extends BskyAgent { constructor({service}: {service: string}) { super({ service, + async fetch(...args) { + let success = false + try { + const result = await realFetch(...args) + success = true + return result + } catch (e) { + success = false + throw e + } finally { + if (success) { + emitNetworkConfirmed() + } else { + emitNetworkLost() + } + } + }, persistSession: (event: AtpSessionEvent) => { if (this.persistSessionHandler) { this.persistSessionHandler(event) @@ -257,7 +276,15 @@ class BskyAppAgent extends BskyAgent { // Now the agent is ready. const account = agentToSessionAccountOrThrow(this) + let lastSession = this.sessionManager.session this.persistSessionHandler = event => { + if (this.sessionManager.session) { + lastSession = this.sessionManager.session + } else if (event === 'network-error') { + // Put it back, we'll try again later. + this.sessionManager.session = lastSession + } + onSessionChange(this, account.did, event) if (event !== 'create' && event !== 'update') { addSessionErrorLog(account.did, event) diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts index 0a537b42c6..b49198514c 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -79,12 +79,8 @@ let reducer = (state: State, action: Action): State => { return state } if (sessionEvent === 'network-error') { - // Don't change stored accounts but kick to the choose account screen. - return { - accounts: state.accounts, - currentAgentState: createPublicAgentState(), - needsPersist: true, - } + // Assume it's transient. + return state } const existingAccount = state.accounts.find(a => a.did === accountDid) if ( From 630ebf523d2e70db295ef1dc1705ea38c05104af Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 13 Aug 2024 22:00:03 +0100 Subject: [PATCH 54/67] [Video] Try/catch video play/pause (#4930) Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 16 ++++++++++++---- .../com/util/post-embeds/VideoPlayerContext.tsx | 12 +++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index 5cbe018722..11fff4796a 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -8,6 +8,7 @@ import {useIsFocused} from '@react-navigation/native' import {HITSLOP_30} from '#/lib/constants' import {useAppState} from '#/lib/hooks/useAppState' +import {logger} from '#/logger' import {useVideoPlayer} from '#/view/com/util/post-embeds/VideoPlayerContext' import {android, atoms as a, useTheme} from '#/alf' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' @@ -25,10 +26,17 @@ export function VideoEmbedInnerNative() { const isAppFocused = useAppState() useEffect(() => { - if (isAppFocused === 'active' && isScreenFocused && !player.playing) { - player.play() - } else if (player.playing) { - player.pause() + try { + if (isAppFocused === 'active' && isScreenFocused && !player.playing) { + player.play() + } else if (player.playing) { + player.pause() + } + } catch (err) { + logger.error( + 'Failed to play/pause while backgrounding/switching screens', + {safeMessage: err}, + ) } }, [isAppFocused, player, isScreenFocused]) diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.tsx index 8f2d11f6bc..20ebb6d2fd 100644 --- a/src/view/com/util/post-embeds/VideoPlayerContext.tsx +++ b/src/view/com/util/post-embeds/VideoPlayerContext.tsx @@ -2,6 +2,8 @@ import React, {useContext} from 'react' import type {VideoPlayer} from 'expo-video' import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video' +import {logger} from '#/logger' + const VideoPlayerContext = React.createContext(null) export function VideoPlayerProvider({ @@ -13,9 +15,13 @@ export function VideoPlayerProvider({ }) { // eslint-disable-next-line @typescript-eslint/no-shadow const player = useExpoVideoPlayer(source, player => { - player.loop = true - player.muted = true - player.play() + try { + player.loop = true + player.muted = true + player.play() + } catch (err) { + logger.error('Failed to init video player', {safeMessage: err}) + } }) return ( From 26d3777ecc7192835f4b14a9fad775d8044e29f9 Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 13 Aug 2024 17:35:05 -0700 Subject: [PATCH 55/67] Add `/live/` to supported YouTube embed URLs (#4932) --- __tests__/lib/string.test.ts | 8 ++++++++ src/lib/strings/embed-player.ts | 15 ++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 0da9551e30..f226de992b 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -340,12 +340,14 @@ describe('parseEmbedPlayerFromUrl', () => { 'https://youtube.com/watch?v=videoId', 'https://youtube.com/watch?v=videoId&feature=share', 'https://youtube.com/shorts/videoId', + 'https://youtube.com/live/videoId', 'https://m.youtube.com/watch?v=videoId', 'https://music.youtube.com/watch?v=videoId', 'https://youtube.com/shorts/', 'https://youtube.com/', 'https://youtube.com/random', + 'https://youtube.com/live/', 'https://twitch.tv/channelName', 'https://www.twitch.tv/channelName', @@ -475,10 +477,16 @@ describe('parseEmbedPlayerFromUrl', () => { source: 'youtube', playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0', }, + { + type: 'youtube_video', + source: 'youtube', + playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0', + }, undefined, undefined, undefined, + undefined, { type: 'twitch_video', diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts index 44e42fae1c..3bae771c0a 100644 --- a/src/lib/strings/embed-player.ts +++ b/src/lib/strings/embed-player.ts @@ -103,16 +103,21 @@ export function parseEmbedPlayerFromUrl( urlp.hostname === 'm.youtube.com' || urlp.hostname === 'music.youtube.com' ) { - const [_, page, shortVideoId] = urlp.pathname.split('/') + const [_, page, shortOrLiveVideoId] = urlp.pathname.split('/') + + const isShorts = page === 'shorts' + const isLive = page === 'live' const videoId = - page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string) + isShorts || isLive + ? shortOrLiveVideoId + : (urlp.searchParams.get('v') as string) const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0) if (videoId) { return { - type: page === 'shorts' ? 'youtube_short' : 'youtube_video', - source: page === 'shorts' ? 'youtubeShorts' : 'youtube', - hideDetails: page === 'shorts' ? true : undefined, + type: isShorts ? 'youtube_short' : 'youtube_video', + source: isShorts ? 'youtubeShorts' : 'youtube', + hideDetails: isShorts ? true : undefined, playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`, } } From 21e214c23579e5ca45fed3ec563d4010e37562a2 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 14 Aug 2024 20:21:14 +0100 Subject: [PATCH 56/67] [Video] set audio category to ambient every time a new player is made (#4934) * set auto category to ambient every time a new player is made * mute on foregrounding * remember previous state --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> Co-authored-by: Hailey --- .../ios/PlatformInfo/ExpoPlatformInfoModule.swift | 12 +++++++++++- src/view/com/composer/videos/VideoPreview.tsx | 2 +- .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 3 +++ src/view/com/util/post-embeds/VideoPlayerContext.tsx | 7 +++++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift index cae4b983d1..02bf5c6628 100644 --- a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift +++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift @@ -1,6 +1,9 @@ import ExpoModulesCore public class ExpoPlatformInfoModule: Module { + private var prevAudioActive: Bool? + private var prevAudioCategory: AVAudioSession.Category? + public func definition() -> ModuleDefinition { Name("ExpoPlatformInfo") @@ -10,13 +13,20 @@ public class ExpoPlatformInfoModule: Module { Function("setAudioCategory") { (audioCategoryString: String) in let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString) - + if audioCategory == self.prevAudioCategory { + return + } + self.prevAudioCategory = audioCategory DispatchQueue.global(qos: .background).async { try? AVAudioSession.sharedInstance().setCategory(audioCategory) } } Function("setAudioActive") { (active: Bool) in + if active == self.prevAudioActive { + return + } + self.prevAudioActive = active if active { DispatchQueue.global(qos: .background).async { try? AVAudioSession.sharedInstance().setActive(true) diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index 8e2a22852d..6956c8c4f8 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -16,8 +16,8 @@ export function VideoPreview({ }) { const player = useVideoPlayer(video.uri, player => { player.loop = true + player.muted = true player.play() - player.volume = 0 }) return ( diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index 11fff4796a..fa49438763 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -28,6 +28,9 @@ export function VideoEmbedInnerNative() { useEffect(() => { try { if (isAppFocused === 'active' && isScreenFocused && !player.playing) { + PlatformInfo.setAudioCategory(AudioCategory.Ambient) + PlatformInfo.setAudioActive(false) + player.muted = true player.play() } else if (player.playing) { player.pause() diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.tsx index 20ebb6d2fd..95511099e4 100644 --- a/src/view/com/util/post-embeds/VideoPlayerContext.tsx +++ b/src/view/com/util/post-embeds/VideoPlayerContext.tsx @@ -3,6 +3,10 @@ import type {VideoPlayer} from 'expo-video' import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video' import {logger} from '#/logger' +import { + AudioCategory, + PlatformInfo, +} from '../../../../../modules/expo-bluesky-swiss-army' const VideoPlayerContext = React.createContext(null) @@ -16,6 +20,9 @@ export function VideoPlayerProvider({ // eslint-disable-next-line @typescript-eslint/no-shadow const player = useExpoVideoPlayer(source, player => { try { + PlatformInfo.setAudioCategory(AudioCategory.Ambient) + PlatformInfo.setAudioActive(false) + player.loop = true player.muted = true player.play() From b6fa0d2d048b3c68d47d6fe502ca1b52096eb4c9 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 14 Aug 2024 21:01:59 +0100 Subject: [PATCH 57/67] [Embed] Starter pack embed embed (#4935) * update @atproto/api * add starter pack embed * update depreciated BskyAgent to AtpAgent * unrelated, but avoid direct import of type * nits * rm commented out code --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- bskyembed/.eslintrc | 5 +- bskyembed/assets/starterPack.svg | 1 + bskyembed/package.json | 2 +- bskyembed/src/components/embed.tsx | 92 +++++++++++++++---- bskyembed/src/components/post.tsx | 5 +- bskyembed/src/screens/post.tsx | 4 +- bskyembed/yarn.lock | 66 +++++++++---- .../StarterPack/StarterPackCard.tsx | 21 +++-- 8 files changed, 147 insertions(+), 49 deletions(-) create mode 100644 bskyembed/assets/starterPack.svg diff --git a/bskyembed/.eslintrc b/bskyembed/.eslintrc index e6e575a11c..2b290d5815 100644 --- a/bskyembed/.eslintrc +++ b/bskyembed/.eslintrc @@ -10,11 +10,12 @@ ], "rules": { "simple-import-sort/imports": "warn", - "simple-import-sort/exports": "warn" + "simple-import-sort/exports": "warn", + 'no-else-return': 'off' }, "parserOptions": { "sourceType": "module", "ecmaVersion": "latest", "project": "./bskyembed/tsconfig.json" } -} \ No newline at end of file +} diff --git a/bskyembed/assets/starterPack.svg b/bskyembed/assets/starterPack.svg new file mode 100644 index 0000000000..eb8dd710fe --- /dev/null +++ b/bskyembed/assets/starterPack.svg @@ -0,0 +1 @@ + diff --git a/bskyembed/package.json b/bskyembed/package.json index f610e8c064..cb9a46213b 100644 --- a/bskyembed/package.json +++ b/bskyembed/package.json @@ -9,7 +9,7 @@ "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src" }, "dependencies": { - "@atproto/api": "^0.12.2", + "@atproto/api": "0.13.1", "@preact/preset-vite": "^2.8.2", "@vitejs/plugin-legacy": "^5.3.2", "preact": "^10.4.8", diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx index 1dadfee38e..600c7c2c3a 100644 --- a/bskyembed/src/components/embed.tsx +++ b/bskyembed/src/components/embed.tsx @@ -6,12 +6,15 @@ import { AppBskyFeedDefs, AppBskyFeedPost, AppBskyGraphDefs, + AppBskyGraphStarterpack, AppBskyLabelerDefs, + AtUri, } from '@atproto/api' import {ComponentChildren, h} from 'preact' import {useMemo} from 'preact/hooks' import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' +import starterPackIcon from '../../assets/starterPack.svg' import {CONTENT_LABELS, labelsToInfo} from '../labels' import {getRkey} from '../utils' import {Link} from './link' @@ -105,7 +108,7 @@ export function Embed({ // Case 3.2: List if (AppBskyGraphDefs.isListView(record)) { return ( - - ) + // Embed type does not exist in the app, so show nothing + return null } - // Case 3.5: Post not found + // Case 3.5: Starter pack + if (AppBskyGraphDefs.isStarterPackViewBasic(record)) { + return + } + + // Case 3.6: Post not found if (AppBskyEmbedRecord.isViewNotFound(record)) { return Quoted post not found, it may have been deleted. } - // Case 3.6: Post blocked + // Case 3.7: Post blocked if (AppBskyEmbedRecord.isViewBlocked(record)) { return The quoted post is blocked. } - throw new Error('Unknown embed type') + // Unknown embed type + return null } // Case 4: Record with media @@ -182,7 +184,8 @@ export function Embed({ ) } - throw new Error('Unsupported embed type') + // Unknown embed type + return null } catch (err) { return ( {err instanceof Error ? err.message : 'An error occurred'} @@ -314,7 +317,7 @@ function ExternalEmbed({ ) } -function GenericWithImage({ +function GenericWithImageEmbed({ title, subtitle, href, @@ -350,3 +353,60 @@ function GenericWithImage({ ) } + +function StarterPackEmbed({ + content, +}: { + content: AppBskyGraphDefs.StarterPackViewBasic +}) { + if (!AppBskyGraphStarterpack.isRecord(content.record)) { + return null + } + + const starterPackHref = getStarterPackHref(content) + const imageUri = getStarterPackImage(content) + + return ( + + +
+
+ +
+

+ {content.record.name} +

+

+ Starter pack by{' '} + {content.creator.displayName || `@${content.creator.handle}`} +

+
+
+ {content.record.description && ( +

{content.record.description}

+ )} + {!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && ( +

+ {content.joinedAllTimeCount} users have joined! +

+ )} +
+ + ) +} + +// from #/lib/strings/starter-pack.ts +function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) { + const rkey = new AtUri(starterPack.uri).rkey + return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}` +} + +function getStarterPackHref( + starterPack: AppBskyGraphDefs.StarterPackViewBasic, +) { + const rkey = new AtUri(starterPack.uri).rkey + const handleOrDid = starterPack.creator.handle || starterPack.creator.did + return `/starter-pack/${handleOrDid}/${rkey}` +} diff --git a/bskyembed/src/components/post.tsx b/bskyembed/src/components/post.tsx index d23c84cbfb..1d1e8f4d81 100644 --- a/bskyembed/src/components/post.tsx +++ b/bskyembed/src/components/post.tsx @@ -132,7 +132,10 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) { key={counter} href={segment.link.uri} className="text-blue-400 hover:underline" - disableTracking={!segment.link.uri.startsWith('https://bsky.app')}> + disableTracking={ + !segment.link.uri.startsWith('https://bsky.app') && + !segment.link.uri.startsWith('https://go.bsky.app') + }> {segment.text} , ) diff --git a/bskyembed/src/screens/post.tsx b/bskyembed/src/screens/post.tsx index 337bf01007..6ccf10a791 100644 --- a/bskyembed/src/screens/post.tsx +++ b/bskyembed/src/screens/post.tsx @@ -1,6 +1,6 @@ import '../index.css' -import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' +import {AppBskyFeedDefs, AtpAgent} from '@atproto/api' import {h, render} from 'preact' import logo from '../../assets/logo.svg' @@ -12,7 +12,7 @@ import {getRkey} from '../utils' const root = document.getElementById('app') if (!root) throw new Error('No root element') -const agent = new BskyAgent({ +const agent = new AtpAgent({ service: 'https://public.api.bsky.app', }) diff --git a/bskyembed/yarn.lock b/bskyembed/yarn.lock index 60efe36845..ca52dc0747 100644 --- a/bskyembed/yarn.lock +++ b/bskyembed/yarn.lock @@ -20,15 +20,16 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@atproto/api@^0.12.2": - version "0.12.2" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.2.tgz#5df6d4f60dea0395c84fdebd9e81a7e853edf130" - integrity sha512-UVzCiDZH2j0wrr/O8nb1edD5cYLVqB5iujueXUCbHS3rAwIxgmyLtA3Hzm2QYsGPo/+xsIg1fNvpq9rNT6KWUA== +"@atproto/api@0.13.1": + version "0.13.1" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0" + integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw== dependencies: "@atproto/common-web" "^0.3.0" - "@atproto/lexicon" "^0.4.0" + "@atproto/lexicon" "^0.4.1" "@atproto/syntax" "^0.3.0" - "@atproto/xrpc" "^0.5.0" + "@atproto/xrpc" "^0.6.0" + await-lock "^2.2.2" multiformats "^9.9.0" tlds "^1.234.0" @@ -42,29 +43,29 @@ uint8arrays "3.0.0" zod "^3.21.4" -"@atproto/lexicon@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576" - integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ== +"@atproto/lexicon@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.1.tgz#19155210570a2fafbcc7d4f655d9b813948e72a0" + integrity sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/syntax" "^0.3.0" iso-datestring-validator "^2.2.2" multiformats "^9.9.0" - zod "^3.21.4" + zod "^3.23.8" "@atproto/syntax@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3" integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA== -"@atproto/xrpc@^0.5.0": - version "0.5.0" - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032" - integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog== +"@atproto/xrpc@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c" + integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ== dependencies: - "@atproto/lexicon" "^0.4.0" - zod "^3.21.4" + "@atproto/lexicon" "^0.4.1" + zod "^3.23.8" "@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2": version "7.24.2" @@ -1710,6 +1711,11 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" +await-lock@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef" + integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw== + babel-plugin-polyfill-corejs2@^0.4.10: version "0.4.10" resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz#276f41710b03a64f6467433cab72cbc2653c38b1" @@ -3730,8 +3736,16 @@ stack-trace@^1.0.0-pre2: resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-1.0.0-pre2.tgz#46a83a79f1b287807e9aaafc6a5dd8bcde626f9c" integrity sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -3795,7 +3809,14 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4192,3 +4213,8 @@ zod@^3.21.4: version "3.22.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg== + +zod@^3.23.8: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx index dc9e4b70da..4c4bf246ea 100644 --- a/src/components/StarterPack/StarterPackCard.tsx +++ b/src/components/StarterPack/StarterPackCard.tsx @@ -1,8 +1,7 @@ import React from 'react' import {View} from 'react-native' import {Image} from 'expo-image' -import {AppBskyGraphStarterpack, AtUri} from '@atproto/api' -import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs' +import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' @@ -17,7 +16,11 @@ import {StarterPack} from '#/components/icons/StarterPack' import {BaseLink} from '#/components/Link' import {Text} from '#/components/Typography' -export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) { +export function Default({ + starterPack, +}: { + starterPack?: AppBskyGraphDefs.StarterPackViewBasic +}) { if (!starterPack) return null return ( @@ -29,7 +32,7 @@ export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) { export function Notification({ starterPack, }: { - starterPack?: StarterPackViewBasic + starterPack?: AppBskyGraphDefs.StarterPackViewBasic }) { if (!starterPack) return null return ( @@ -44,7 +47,7 @@ export function Card({ noIcon, noDescription, }: { - starterPack: StarterPackViewBasic + starterPack: AppBskyGraphDefs.StarterPackViewBasic noIcon?: boolean noDescription?: boolean }) { @@ -94,7 +97,7 @@ export function Link({ starterPack, children, }: { - starterPack: StarterPackViewBasic + starterPack: AppBskyGraphDefs.StarterPackViewBasic onPress?: () => void children: React.ReactNode }) { @@ -129,7 +132,11 @@ export function Link({ ) } -export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) { +export function Embed({ + starterPack, +}: { + starterPack: AppBskyGraphDefs.StarterPackViewBasic +}) { const t = useTheme() const imageUri = getStarterPackOgCard(starterPack) From b9975697e22ef729e60b9111883127961258445b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 14 Aug 2024 21:08:17 +0100 Subject: [PATCH 58/67] swap control files (#4936) Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- .../VideoEmbedInnerNative.web.tsx | 2 +- .../VideoWebControls.native.tsx | 3 + .../VideoEmbedInner/VideoWebControls.tsx | 579 ++++++++++++++++- .../VideoEmbedInner/VideoWebControls.web.tsx | 587 ------------------ 4 files changed, 579 insertions(+), 592 deletions(-) create mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx index 59da5be42a..2760c7fafd 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx @@ -1,3 +1,3 @@ export function VideoEmbedInnerNative() { - throw new Error('VideoEmbedInnerNative may not be used on native.') + throw new Error('VideoEmbedInnerNative may not be used on web.') } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx new file mode 100644 index 0000000000..e2e24ed367 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx @@ -0,0 +1,3 @@ +export function Controls() { + throw new Error('VideoWebControls may not be used on native.') +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx index 11e0867e43..7caaf3abf7 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx @@ -1,7 +1,51 @@ -import React from 'react' +import React, { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from 'react' +import {Pressable, View} from 'react-native' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import type Hls from 'hls.js' -export function Controls({}: { +import {isIPhoneWeb} from 'platform/detection' +import { + useAutoplayDisabled, + useSetSubtitlesEnabled, + useSubtitlesEnabled, +} from 'state/preferences' +import {atoms as a, useTheme, web} from '#/alf' +import {Button} from '#/components/Button' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import { + ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, + ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, +} from '#/components/icons/ArrowsDiagonal' +import { + CC_Filled_Corner0_Rounded as CCActiveIcon, + CC_Stroke2_Corner0_Rounded as CCInactiveIcon, +} from '#/components/icons/CC' +import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' +import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' +import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +export function Controls({ + videoRef, + hlsRef, + active, + setActive, + focused, + setFocused, + onScreen, + fullscreenRef, + hasSubtitleTrack, +}: { videoRef: React.RefObject hlsRef: React.RefObject active: boolean @@ -11,6 +55,533 @@ export function Controls({}: { onScreen: boolean fullscreenRef: React.RefObject hasSubtitleTrack: boolean -}): React.ReactElement { - throw new Error('Web-only component') +}) { + const { + play, + pause, + playing, + muted, + toggleMute, + togglePlayPause, + currentTime, + duration, + buffering, + error, + canPlay, + } = useVideoUtils(videoRef) + const t = useTheme() + const {_} = useLingui() + const subtitlesEnabled = useSubtitlesEnabled() + const setSubtitlesEnabled = useSetSubtitlesEnabled() + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) + const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() + const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) + + const onKeyDown = useCallback(() => { + setInteractingViaKeypress(true) + }, []) + + useEffect(() => { + if (interactingViaKeypress) { + document.addEventListener('click', () => setInteractingViaKeypress(false)) + return () => { + document.removeEventListener('click', () => + setInteractingViaKeypress(false), + ) + } + } + }, [interactingViaKeypress]) + + // pause + unfocus when another video is active + useEffect(() => { + if (!active) { + pause() + setFocused(false) + } + }, [active, pause, setFocused]) + + // autoplay/pause based on visibility + const autoplayDisabled = useAutoplayDisabled() + useEffect(() => { + if (active && !autoplayDisabled) { + if (onScreen) { + play() + } else { + pause() + } + } + }, [onScreen, pause, active, play, autoplayDisabled]) + + // use minimal quality when not focused + useEffect(() => { + if (!hlsRef.current) return + if (focused) { + // auto decide quality based on network conditions + hlsRef.current.autoLevelCapping = -1 + } else { + hlsRef.current.autoLevelCapping = 0 + } + }, [hlsRef, focused]) + + useEffect(() => { + if (!hlsRef.current) return + if (hasSubtitleTrack && subtitlesEnabled && canPlay) { + hlsRef.current.subtitleTrack = 0 + } else { + hlsRef.current.subtitleTrack = -1 + } + }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) + + // clicking on any button should focus the player, if it's not already focused + const drawFocus = useCallback(() => { + if (!active) { + setActive() + } + setFocused(true) + }, [active, setActive, setFocused]) + + const onPressEmptySpace = useCallback(() => { + if (!focused) { + drawFocus() + } else { + togglePlayPause() + } + }, [togglePlayPause, drawFocus, focused]) + + const onPressPlayPause = useCallback(() => { + drawFocus() + togglePlayPause() + }, [drawFocus, togglePlayPause]) + + const onPressSubtitles = useCallback(() => { + drawFocus() + setSubtitlesEnabled(!subtitlesEnabled) + }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) + + const onPressMute = useCallback(() => { + drawFocus() + toggleMute() + }, [drawFocus, toggleMute]) + + const onPressFullscreen = useCallback(() => { + drawFocus() + toggleFullscreen() + }, [drawFocus, toggleFullscreen]) + + const showControls = + (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered) + + return ( +
{ + evt.stopPropagation() + setInteractingViaKeypress(false) + }} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={onKeyDown}> + + + + + + {formatTime(currentTime)} / {formatTime(duration)} + + {hasSubtitleTrack && ( + + )} + + {!isIPhoneWeb && ( + + )} + + {(showControls || !focused) && ( + + {duration > 0 && ( + + )} + + )} + {(buffering || error) && ( + + {buffering && } + {error && ( + + An error occurred + + )} + + )} +
+ ) +} + +const btnProps = { + variant: 'ghost', + shape: 'round', + size: 'medium', + style: a.p_2xs, + hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'}, +} as const + +function formatTime(time: number) { + if (isNaN(time)) { + return '--' + } + + time = Math.round(time) + + const minutes = Math.floor(time / 60) + const seconds = String(time % 60).padStart(2, '0') + + return `${minutes}:${seconds}` +} + +function useVideoUtils(ref: React.RefObject) { + const [playing, setPlaying] = useState(false) + const [muted, setMuted] = useState(true) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [buffering, setBuffering] = useState(false) + const [error, setError] = useState(false) + const [canPlay, setCanPlay] = useState(false) + const playWhenReadyRef = useRef(false) + + useEffect(() => { + if (!ref.current) return + + let bufferingTimeout: ReturnType | undefined + + function round(num: number) { + return Math.round(num * 100) / 100 + } + + // Initial values + setCurrentTime(round(ref.current.currentTime) || 0) + setDuration(round(ref.current.duration) || 0) + setMuted(ref.current.muted) + setPlaying(!ref.current.paused) + + const handleTimeUpdate = () => { + if (!ref.current) return + setCurrentTime(round(ref.current.currentTime) || 0) + } + + const handleDurationChange = () => { + if (!ref.current) return + setDuration(round(ref.current.duration) || 0) + } + + const handlePlay = () => { + setPlaying(true) + } + + const handlePause = () => { + setPlaying(false) + } + + const handleVolumeChange = () => { + if (!ref.current) return + setMuted(ref.current.muted) + } + + const handleError = () => { + setError(true) + } + + const handleCanPlay = () => { + setBuffering(false) + setCanPlay(true) + + if (!ref.current) return + if (playWhenReadyRef.current) { + ref.current.play() + playWhenReadyRef.current = false + } + } + + const handleCanPlayThrough = () => { + setBuffering(false) + } + + const handleWaiting = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + bufferingTimeout = setTimeout(() => { + setBuffering(true) + }, 200) // Delay to avoid frequent buffering state changes + } + + const handlePlaying = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + setError(false) + } + + const handleSeeking = () => { + setBuffering(true) + } + + const handleSeeked = () => { + setBuffering(false) + } + + const handleStalled = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + bufferingTimeout = setTimeout(() => { + setBuffering(true) + }, 200) // Delay to avoid frequent buffering state changes + } + + const handleEnded = () => { + setPlaying(false) + setBuffering(false) + setError(false) + } + + const abortController = new AbortController() + + ref.current.addEventListener('timeupdate', handleTimeUpdate, { + signal: abortController.signal, + }) + ref.current.addEventListener('durationchange', handleDurationChange, { + signal: abortController.signal, + }) + ref.current.addEventListener('play', handlePlay, { + signal: abortController.signal, + }) + ref.current.addEventListener('pause', handlePause, { + signal: abortController.signal, + }) + ref.current.addEventListener('volumechange', handleVolumeChange, { + signal: abortController.signal, + }) + ref.current.addEventListener('error', handleError, { + signal: abortController.signal, + }) + ref.current.addEventListener('canplay', handleCanPlay, { + signal: abortController.signal, + }) + ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { + signal: abortController.signal, + }) + ref.current.addEventListener('waiting', handleWaiting, { + signal: abortController.signal, + }) + ref.current.addEventListener('playing', handlePlaying, { + signal: abortController.signal, + }) + ref.current.addEventListener('seeking', handleSeeking, { + signal: abortController.signal, + }) + ref.current.addEventListener('seeked', handleSeeked, { + signal: abortController.signal, + }) + ref.current.addEventListener('stalled', handleStalled, { + signal: abortController.signal, + }) + ref.current.addEventListener('ended', handleEnded, { + signal: abortController.signal, + }) + + return () => { + abortController.abort() + clearTimeout(bufferingTimeout) + } + }, [ref]) + + const play = useCallback(() => { + if (!ref.current) return + + if (ref.current.ended) { + ref.current.currentTime = 0 + } + + if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { + playWhenReadyRef.current = true + } else { + const promise = ref.current.play() + if (promise !== undefined) { + promise.catch(err => { + console.error('Error playing video:', err) + }) + } + } + }, [ref]) + + const pause = useCallback(() => { + if (!ref.current) return + + ref.current.pause() + playWhenReadyRef.current = false + }, [ref]) + + const togglePlayPause = useCallback(() => { + if (!ref.current) return + + if (ref.current.paused) { + play() + } else { + pause() + } + }, [ref, play, pause]) + + const mute = useCallback(() => { + if (!ref.current) return + + ref.current.muted = true + }, [ref]) + + const unmute = useCallback(() => { + if (!ref.current) return + + ref.current.muted = false + }, [ref]) + + const toggleMute = useCallback(() => { + if (!ref.current) return + + ref.current.muted = !ref.current.muted + }, [ref]) + + return { + play, + pause, + togglePlayPause, + duration, + currentTime, + playing, + muted, + mute, + unmute, + toggleMute, + buffering, + error, + canPlay, + } +} + +function fullscreenSubscribe(onChange: () => void) { + document.addEventListener('fullscreenchange', onChange) + return () => document.removeEventListener('fullscreenchange', onChange) +} + +function useFullscreen(ref: React.RefObject) { + const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () => + Boolean(document.fullscreenElement), + ) + + const toggleFullscreen = useCallback(() => { + if (isFullscreen) { + document.exitFullscreen() + } else { + if (!ref.current) return + ref.current.requestFullscreen() + } + }, [isFullscreen, ref]) + + return [isFullscreen, toggleFullscreen] as const } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx deleted file mode 100644 index 7caaf3abf7..0000000000 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx +++ /dev/null @@ -1,587 +0,0 @@ -import React, { - useCallback, - useEffect, - useRef, - useState, - useSyncExternalStore, -} from 'react' -import {Pressable, View} from 'react-native' -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import type Hls from 'hls.js' - -import {isIPhoneWeb} from 'platform/detection' -import { - useAutoplayDisabled, - useSetSubtitlesEnabled, - useSubtitlesEnabled, -} from 'state/preferences' -import {atoms as a, useTheme, web} from '#/alf' -import {Button} from '#/components/Button' -import {useInteractionState} from '#/components/hooks/useInteractionState' -import { - ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, - ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, -} from '#/components/icons/ArrowsDiagonal' -import { - CC_Filled_Corner0_Rounded as CCActiveIcon, - CC_Stroke2_Corner0_Rounded as CCInactiveIcon, -} from '#/components/icons/CC' -import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' -import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' -import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' -import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' -import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' - -export function Controls({ - videoRef, - hlsRef, - active, - setActive, - focused, - setFocused, - onScreen, - fullscreenRef, - hasSubtitleTrack, -}: { - videoRef: React.RefObject - hlsRef: React.RefObject - active: boolean - setActive: () => void - focused: boolean - setFocused: (focused: boolean) => void - onScreen: boolean - fullscreenRef: React.RefObject - hasSubtitleTrack: boolean -}) { - const { - play, - pause, - playing, - muted, - toggleMute, - togglePlayPause, - currentTime, - duration, - buffering, - error, - canPlay, - } = useVideoUtils(videoRef) - const t = useTheme() - const {_} = useLingui() - const subtitlesEnabled = useSubtitlesEnabled() - const setSubtitlesEnabled = useSetSubtitlesEnabled() - const { - state: hovered, - onIn: onMouseEnter, - onOut: onMouseLeave, - } = useInteractionState() - const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) - const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() - const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) - - const onKeyDown = useCallback(() => { - setInteractingViaKeypress(true) - }, []) - - useEffect(() => { - if (interactingViaKeypress) { - document.addEventListener('click', () => setInteractingViaKeypress(false)) - return () => { - document.removeEventListener('click', () => - setInteractingViaKeypress(false), - ) - } - } - }, [interactingViaKeypress]) - - // pause + unfocus when another video is active - useEffect(() => { - if (!active) { - pause() - setFocused(false) - } - }, [active, pause, setFocused]) - - // autoplay/pause based on visibility - const autoplayDisabled = useAutoplayDisabled() - useEffect(() => { - if (active && !autoplayDisabled) { - if (onScreen) { - play() - } else { - pause() - } - } - }, [onScreen, pause, active, play, autoplayDisabled]) - - // use minimal quality when not focused - useEffect(() => { - if (!hlsRef.current) return - if (focused) { - // auto decide quality based on network conditions - hlsRef.current.autoLevelCapping = -1 - } else { - hlsRef.current.autoLevelCapping = 0 - } - }, [hlsRef, focused]) - - useEffect(() => { - if (!hlsRef.current) return - if (hasSubtitleTrack && subtitlesEnabled && canPlay) { - hlsRef.current.subtitleTrack = 0 - } else { - hlsRef.current.subtitleTrack = -1 - } - }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) - - // clicking on any button should focus the player, if it's not already focused - const drawFocus = useCallback(() => { - if (!active) { - setActive() - } - setFocused(true) - }, [active, setActive, setFocused]) - - const onPressEmptySpace = useCallback(() => { - if (!focused) { - drawFocus() - } else { - togglePlayPause() - } - }, [togglePlayPause, drawFocus, focused]) - - const onPressPlayPause = useCallback(() => { - drawFocus() - togglePlayPause() - }, [drawFocus, togglePlayPause]) - - const onPressSubtitles = useCallback(() => { - drawFocus() - setSubtitlesEnabled(!subtitlesEnabled) - }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) - - const onPressMute = useCallback(() => { - drawFocus() - toggleMute() - }, [drawFocus, toggleMute]) - - const onPressFullscreen = useCallback(() => { - drawFocus() - toggleFullscreen() - }, [drawFocus, toggleFullscreen]) - - const showControls = - (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered) - - return ( -
{ - evt.stopPropagation() - setInteractingViaKeypress(false) - }} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - onFocus={onFocus} - onBlur={onBlur} - onKeyDown={onKeyDown}> - - - - - - {formatTime(currentTime)} / {formatTime(duration)} - - {hasSubtitleTrack && ( - - )} - - {!isIPhoneWeb && ( - - )} - - {(showControls || !focused) && ( - - {duration > 0 && ( - - )} - - )} - {(buffering || error) && ( - - {buffering && } - {error && ( - - An error occurred - - )} - - )} -
- ) -} - -const btnProps = { - variant: 'ghost', - shape: 'round', - size: 'medium', - style: a.p_2xs, - hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'}, -} as const - -function formatTime(time: number) { - if (isNaN(time)) { - return '--' - } - - time = Math.round(time) - - const minutes = Math.floor(time / 60) - const seconds = String(time % 60).padStart(2, '0') - - return `${minutes}:${seconds}` -} - -function useVideoUtils(ref: React.RefObject) { - const [playing, setPlaying] = useState(false) - const [muted, setMuted] = useState(true) - const [currentTime, setCurrentTime] = useState(0) - const [duration, setDuration] = useState(0) - const [buffering, setBuffering] = useState(false) - const [error, setError] = useState(false) - const [canPlay, setCanPlay] = useState(false) - const playWhenReadyRef = useRef(false) - - useEffect(() => { - if (!ref.current) return - - let bufferingTimeout: ReturnType | undefined - - function round(num: number) { - return Math.round(num * 100) / 100 - } - - // Initial values - setCurrentTime(round(ref.current.currentTime) || 0) - setDuration(round(ref.current.duration) || 0) - setMuted(ref.current.muted) - setPlaying(!ref.current.paused) - - const handleTimeUpdate = () => { - if (!ref.current) return - setCurrentTime(round(ref.current.currentTime) || 0) - } - - const handleDurationChange = () => { - if (!ref.current) return - setDuration(round(ref.current.duration) || 0) - } - - const handlePlay = () => { - setPlaying(true) - } - - const handlePause = () => { - setPlaying(false) - } - - const handleVolumeChange = () => { - if (!ref.current) return - setMuted(ref.current.muted) - } - - const handleError = () => { - setError(true) - } - - const handleCanPlay = () => { - setBuffering(false) - setCanPlay(true) - - if (!ref.current) return - if (playWhenReadyRef.current) { - ref.current.play() - playWhenReadyRef.current = false - } - } - - const handleCanPlayThrough = () => { - setBuffering(false) - } - - const handleWaiting = () => { - if (bufferingTimeout) clearTimeout(bufferingTimeout) - bufferingTimeout = setTimeout(() => { - setBuffering(true) - }, 200) // Delay to avoid frequent buffering state changes - } - - const handlePlaying = () => { - if (bufferingTimeout) clearTimeout(bufferingTimeout) - setBuffering(false) - setError(false) - } - - const handleSeeking = () => { - setBuffering(true) - } - - const handleSeeked = () => { - setBuffering(false) - } - - const handleStalled = () => { - if (bufferingTimeout) clearTimeout(bufferingTimeout) - bufferingTimeout = setTimeout(() => { - setBuffering(true) - }, 200) // Delay to avoid frequent buffering state changes - } - - const handleEnded = () => { - setPlaying(false) - setBuffering(false) - setError(false) - } - - const abortController = new AbortController() - - ref.current.addEventListener('timeupdate', handleTimeUpdate, { - signal: abortController.signal, - }) - ref.current.addEventListener('durationchange', handleDurationChange, { - signal: abortController.signal, - }) - ref.current.addEventListener('play', handlePlay, { - signal: abortController.signal, - }) - ref.current.addEventListener('pause', handlePause, { - signal: abortController.signal, - }) - ref.current.addEventListener('volumechange', handleVolumeChange, { - signal: abortController.signal, - }) - ref.current.addEventListener('error', handleError, { - signal: abortController.signal, - }) - ref.current.addEventListener('canplay', handleCanPlay, { - signal: abortController.signal, - }) - ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { - signal: abortController.signal, - }) - ref.current.addEventListener('waiting', handleWaiting, { - signal: abortController.signal, - }) - ref.current.addEventListener('playing', handlePlaying, { - signal: abortController.signal, - }) - ref.current.addEventListener('seeking', handleSeeking, { - signal: abortController.signal, - }) - ref.current.addEventListener('seeked', handleSeeked, { - signal: abortController.signal, - }) - ref.current.addEventListener('stalled', handleStalled, { - signal: abortController.signal, - }) - ref.current.addEventListener('ended', handleEnded, { - signal: abortController.signal, - }) - - return () => { - abortController.abort() - clearTimeout(bufferingTimeout) - } - }, [ref]) - - const play = useCallback(() => { - if (!ref.current) return - - if (ref.current.ended) { - ref.current.currentTime = 0 - } - - if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { - playWhenReadyRef.current = true - } else { - const promise = ref.current.play() - if (promise !== undefined) { - promise.catch(err => { - console.error('Error playing video:', err) - }) - } - } - }, [ref]) - - const pause = useCallback(() => { - if (!ref.current) return - - ref.current.pause() - playWhenReadyRef.current = false - }, [ref]) - - const togglePlayPause = useCallback(() => { - if (!ref.current) return - - if (ref.current.paused) { - play() - } else { - pause() - } - }, [ref, play, pause]) - - const mute = useCallback(() => { - if (!ref.current) return - - ref.current.muted = true - }, [ref]) - - const unmute = useCallback(() => { - if (!ref.current) return - - ref.current.muted = false - }, [ref]) - - const toggleMute = useCallback(() => { - if (!ref.current) return - - ref.current.muted = !ref.current.muted - }, [ref]) - - return { - play, - pause, - togglePlayPause, - duration, - currentTime, - playing, - muted, - mute, - unmute, - toggleMute, - buffering, - error, - canPlay, - } -} - -function fullscreenSubscribe(onChange: () => void) { - document.addEventListener('fullscreenchange', onChange) - return () => document.removeEventListener('fullscreenchange', onChange) -} - -function useFullscreen(ref: React.RefObject) { - const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () => - Boolean(document.fullscreenElement), - ) - - const toggleFullscreen = useCallback(() => { - if (isFullscreen) { - document.exitFullscreen() - } else { - if (!ref.current) return - ref.current.requestFullscreen() - } - }, [isFullscreen, ref]) - - return [isFullscreen, toggleFullscreen] as const -} From 11061b628ef5b5805c6435155ca2a571001e4643 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 15 Aug 2024 11:23:48 -0700 Subject: [PATCH 59/67] [Video] Download videos (#4886) Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- bskyweb/cmd/bskyweb/server.go | 3 + bskyweb/static/robots.txt | 1 + .../hlsdownload/ExpoHLSDownloadModule.kt | 35 +++ .../hlsdownload/HLSDownloadView.kt | 141 ++++++++++++ .../expo-module.config.json | 4 +- modules/expo-bluesky-swiss-army/index.ts | 10 +- .../HLSDownload/ExpoHLSDownloadModule.swift | 31 +++ .../ios/HLSDownload/HLSDownloadView.swift | 148 ++++++++++++ .../src/HLSDownload/index.native.tsx | 39 ++++ .../src/HLSDownload/index.tsx | 22 ++ .../src/HLSDownload/types.ts | 10 + package.json | 4 + src/Navigation.tsx | 6 + src/components/VideoDownloadScreen.native.tsx | 4 + src/components/VideoDownloadScreen.tsx | 215 ++++++++++++++++++ src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/view/screens/Storybook/index.tsx | 46 +++- yarn.lock | 29 +++ 19 files changed, 747 insertions(+), 3 deletions(-) create mode 100644 modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt create mode 100644 modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt create mode 100644 modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift create mode 100644 modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift create mode 100644 modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx create mode 100644 modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx create mode 100644 modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts create mode 100644 src/components/VideoDownloadScreen.native.tsx create mode 100644 src/components/VideoDownloadScreen.tsx diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index fdef01ce78..01f1a87550 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -256,6 +256,9 @@ func serve(cctx *cli.Context) error { e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric) e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric) + // video download + e.GET("/video-download", server.WebGeneric) + // starter packs e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack) e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack) diff --git a/bskyweb/static/robots.txt b/bskyweb/static/robots.txt index 4f8510d18d..d785755a43 100644 --- a/bskyweb/static/robots.txt +++ b/bskyweb/static/robots.txt @@ -7,3 +7,4 @@ # be ok. User-Agent: * Allow: / +Disallow: /video-download diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt new file mode 100644 index 0000000000..786b84e41c --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt @@ -0,0 +1,35 @@ +package expo.modules.blueskyswissarmy.hlsdownload + +import android.net.Uri +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoHLSDownloadModule : Module() { + override fun definition() = + ModuleDefinition { + Name("ExpoHLSDownload") + + Function("isAvailable") { + return@Function true + } + + View(HLSDownloadView::class) { + Events( + arrayOf( + "onStart", + "onError", + "onProgress", + "onSuccess", + ), + ) + + Prop("downloaderUrl") { view: HLSDownloadView, downloaderUrl: Uri -> + view.downloaderUrl = downloaderUrl + } + + AsyncFunction("startDownloadAsync") { view: HLSDownloadView, sourceUrl: Uri -> + view.startDownload(sourceUrl) + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt new file mode 100644 index 0000000000..5f3082a819 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt @@ -0,0 +1,141 @@ +package expo.modules.blueskyswissarmy.hlsdownload + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.util.Base64 +import android.util.Log +import android.webkit.DownloadListener +import android.webkit.JavascriptInterface +import android.webkit.WebView +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.viewevent.ViewEventCallback +import expo.modules.kotlin.views.ExpoView +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.net.URI +import java.util.UUID + +class HLSDownloadView( + context: Context, + appContext: AppContext, +) : ExpoView(context, appContext), + DownloadListener { + private val webView = WebView(context) + + var downloaderUrl: Uri? = null + + private val onStart by EventDispatcher() + private val onError by EventDispatcher() + private val onProgress by EventDispatcher() + private val onSuccess by EventDispatcher() + + init { + this.setupWebView() + this.addView(this.webView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView() { + val webSettings = this.webView.settings + webSettings.javaScriptEnabled = true + webSettings.domStorageEnabled = true + + webView.setDownloadListener(this) + webView.addJavascriptInterface(WebAppInterface(this.onProgress, this.onError), "AndroidInterface") + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + this.webView.stopLoading() + this.webView.clearHistory() + this.webView.removeAllViews() + this.webView.destroy() + } + + fun startDownload(sourceUrl: Uri) { + if (this.downloaderUrl == null) { + this.onError(mapOf(ERROR_KEY to "Downloader URL is not set.")) + return + } + + val url = URI("${this.downloaderUrl}?videoUrl=$sourceUrl") + this.webView.loadUrl(url.toString()) + this.onStart(mapOf()) + } + + override fun onDownloadStart( + url: String?, + userAgent: String?, + contentDisposition: String?, + mimeType: String?, + contentLength: Long, + ) { + if (url == null) { + this.onError(mapOf(ERROR_KEY to "Failed to retrieve download URL from webview.")) + return + } + + val tempDir = context.cacheDir + val fileName = "${UUID.randomUUID()}.mp4" + val file = File(tempDir, fileName) + + val base64 = url.split(",")[1] + val bytes = Base64.decode(base64, Base64.DEFAULT) + + val fos = FileOutputStream(file) + try { + fos.write(bytes) + } catch (e: Exception) { + Log.e("FileDownload", "Error downloading file", e) + this.onError(mapOf(ERROR_KEY to e.message.toString())) + return + } finally { + fos.close() + } + + val uri = Uri.fromFile(file) + this.onSuccess(mapOf("uri" to uri.toString())) + } + + companion object { + const val ERROR_KEY = "message" + } +} + +public class WebAppInterface( + val onProgress: ViewEventCallback>, + val onError: ViewEventCallback>, +) { + @JavascriptInterface + public fun onMessage(message: String) { + val jsonObject = JSONObject(message) + val action = jsonObject.getString("action") + + when (action) { + "error" -> { + val messageStr = jsonObject.get("messageStr") + if (messageStr !is String) { + this.onError(mapOf(ERROR_KEY to "Failed to decode JSON post message.")) + return + } + this.onError(mapOf(ERROR_KEY to messageStr)) + } + "progress" -> { + val messageFloat = jsonObject.get("messageFloat") + if (messageFloat !is Number) { + this.onError(mapOf(ERROR_KEY to "Failed to decode JSON post message.")) + return + } + this.onProgress(mapOf(PROGRESS_KEY to messageFloat)) + } + } + } + + companion object { + const val PROGRESS_KEY = "progress" + const val ERROR_KEY = "message" + } +} diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json index 4cdc11e993..04411ecf7e 100644 --- a/modules/expo-bluesky-swiss-army/expo-module.config.json +++ b/modules/expo-bluesky-swiss-army/expo-module.config.json @@ -5,6 +5,7 @@ "ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoBlueskyVisibilityViewModule", + "ExpoHLSDownloadModule", "ExpoPlatformInfoModule" ] }, @@ -13,7 +14,8 @@ "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule", "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule", "expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule", - "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule" + "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule", + "expo.modules.blueskyswissarmy.hlsdownload.ExpoHLSDownloadModule" ] } } diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts index 2cf4f36c52..67dc6ee608 100644 --- a/modules/expo-bluesky-swiss-army/index.ts +++ b/modules/expo-bluesky-swiss-army/index.ts @@ -1,7 +1,15 @@ +import HLSDownloadView from './src/HLSDownload' import * as PlatformInfo from './src/PlatformInfo' import {AudioCategory} from './src/PlatformInfo/types' import * as Referrer from './src/Referrer' import * as SharedPrefs from './src/SharedPrefs' import VisibilityView from './src/VisibilityView' -export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView} +export { + AudioCategory, + HLSDownloadView, + PlatformInfo, + Referrer, + SharedPrefs, + VisibilityView, +} diff --git a/modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift b/modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift new file mode 100644 index 0000000000..a9b445e489 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift @@ -0,0 +1,31 @@ +import ExpoModulesCore + +public class ExpoHLSDownloadModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoHLSDownload") + + Function("isAvailable") { + if #available(iOS 14.5, *) { + return true + } + return false + } + + View(HLSDownloadView.self) { + Events([ + "onStart", + "onError", + "onProgress", + "onSuccess" + ]) + + Prop("downloaderUrl") { (view: HLSDownloadView, downloaderUrl: URL) in + view.downloaderUrl = downloaderUrl + } + + AsyncFunction("startDownloadAsync") { (view: HLSDownloadView, sourceUrl: URL) in + view.startDownload(sourceUrl: sourceUrl) + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift b/modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift new file mode 100644 index 0000000000..591c09335b --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift @@ -0,0 +1,148 @@ +import ExpoModulesCore +import WebKit + +class HLSDownloadView: ExpoView, WKScriptMessageHandler, WKNavigationDelegate, WKDownloadDelegate { + var webView: WKWebView! + var downloaderUrl: URL? + + private var onStart = EventDispatcher() + private var onError = EventDispatcher() + private var onProgress = EventDispatcher() + private var onSuccess = EventDispatcher() + + private var outputUrl: URL? + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + + // controller for post message api + let contentController = WKUserContentController() + contentController.add(self, name: "onMessage") + let configuration = WKWebViewConfiguration() + configuration.userContentController = contentController + + // create webview + let webView = WKWebView(frame: .zero, configuration: configuration) + + // Use these for debugging, to see the webview itself + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + webView.layer.masksToBounds = false + webView.backgroundColor = .clear + webView.contentMode = .scaleToFill + + webView.navigationDelegate = self + + self.addSubview(webView) + self.webView = webView + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - view functions + + func startDownload(sourceUrl: URL) { + guard let downloaderUrl = self.downloaderUrl, + let url = URL(string: "\(downloaderUrl.absoluteString)?videoUrl=\(sourceUrl.absoluteString)") else { + self.onError([ + "message": "Downloader URL is not set." + ]) + return + } + + self.onStart() + self.webView.load(URLRequest(url: url)) + } + + // webview message handling + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let response = message.body as? String, + let data = response.data(using: .utf8), + let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else { + self.onError([ + "message": "Failed to decode JSON post message." + ]) + return + } + + switch payload.action { + case .progress: + guard let progress = payload.messageFloat else { + self.onError([ + "message": "Failed to decode JSON post message." + ]) + return + } + self.onProgress([ + "progress": progress + ]) + case .error: + guard let messageStr = payload.messageStr else { + self.onError([ + "message": "Failed to decode JSON post message." + ]) + return + } + self.onError([ + "message": messageStr + ]) + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + guard #available(iOS 14.5, *) else { + return .cancel + } + + if navigationAction.shouldPerformDownload { + return .download + } else { + return .allow + } + } + + // MARK: - wkdownloaddelegate + + @available(iOS 14.5, *) + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + download.delegate = self + } + + @available(iOS 14.5, *) + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + download.delegate = self + } + + @available(iOS 14.5, *) + func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { + let directory = NSTemporaryDirectory() + let fileName = "\(NSUUID().uuidString).mp4" + let url = NSURL.fileURL(withPathComponents: [directory, fileName]) + + self.outputUrl = url + completionHandler(url) + } + + @available(iOS 14.5, *) + func downloadDidFinish(_ download: WKDownload) { + guard let url = self.outputUrl else { + return + } + self.onSuccess([ + "uri": url.absoluteString + ]) + self.outputUrl = nil + } +} + +struct WebViewActionPayload: Decodable { + enum Action: String, Decodable { + case progress, error + } + + let action: Action + let messageStr: String? + let messageFloat: Float? +} diff --git a/modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx new file mode 100644 index 0000000000..92f26192e5 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' +import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' + +import {HLSDownloadViewProps} from './types' + +const NativeModule = requireNativeModule('ExpoHLSDownload') +const NativeView: React.ComponentType< + HLSDownloadViewProps & { + ref: React.RefObject + style: StyleProp + } +> = requireNativeViewManager('ExpoHLSDownload') + +export default class HLSDownloadView extends React.PureComponent { + private nativeRef: React.RefObject = React.createRef() + + constructor(props: HLSDownloadViewProps) { + super(props) + } + + static isAvailable(): boolean { + return NativeModule.isAvailable() + } + + async startDownloadAsync(sourceUrl: string): Promise { + return await this.nativeRef.current.startDownloadAsync(sourceUrl) + } + + render() { + return ( + + ) + } +} diff --git a/modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx new file mode 100644 index 0000000000..93c50497fa --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx @@ -0,0 +1,22 @@ +import React from 'react' + +import {NotImplementedError} from '../NotImplemented' +import {HLSDownloadViewProps} from './types' + +export default class HLSDownloadView extends React.PureComponent { + constructor(props: HLSDownloadViewProps) { + super(props) + } + + static isAvailable(): boolean { + return false + } + + async startDownloadAsync(sourceUrl: string): Promise { + throw new NotImplementedError({sourceUrl}) + } + + render() { + return null + } +} diff --git a/modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts b/modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts new file mode 100644 index 0000000000..6a474d2820 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts @@ -0,0 +1,10 @@ +import {NativeSyntheticEvent} from 'react-native' + +export interface HLSDownloadViewProps { + downloaderUrl: string + onSuccess: (e: NativeSyntheticEvent<{uri: string}>) => void + + onStart?: () => void + onError?: (e: NativeSyntheticEvent<{message: string}>) => void + onProgress?: (e: NativeSyntheticEvent<{progress: number}>) => void +} diff --git a/package.json b/package.json index a4523d988f..088f2faf76 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,8 @@ "@emoji-mart/react": "^1.1.1", "@expo/html-elements": "^0.4.2", "@expo/webpack-config": "^19.0.0", + "@ffmpeg/ffmpeg": "^0.12.10", + "@ffmpeg/util": "^0.12.1", "@floating-ui/dom": "^1.6.3", "@floating-ui/react-dom": "^2.0.8", "@formatjs/intl-locale": "^4.0.0", @@ -143,6 +145,7 @@ "expo-web-browser": "~13.0.3", "fast-text-encoding": "^1.0.6", "history": "^5.3.0", + "hls-parser": "^0.13.3", "hls.js": "^1.5.11", "js-sha256": "^0.9.0", "jwt-decode": "^4.0.0", @@ -224,6 +227,7 @@ "@testing-library/react-native": "^11.5.2", "@tsconfig/react-native": "^2.0.3", "@types/he": "^1.1.2", + "@types/hls-parser": "^0.8.7", "@types/jest": "^29.4.0", "@types/lodash.chunk": "^4.2.7", "@types/lodash.debounce": "^4.0.7", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 79856879c3..0d151427fb 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -50,6 +50,7 @@ import { StarterPackScreenShort, } from '#/screens/StarterPack/StarterPackScreen' import {Wizard} from '#/screens/StarterPack/Wizard' +import {VideoDownloadScreen} from '#/components/VideoDownloadScreen' import {Referrer} from '../modules/expo-bluesky-swiss-army' import {init as initAnalytics} from './lib/analytics/analytics' import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' @@ -364,6 +365,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => Wizard} options={{title: title(msg`Edit your starter pack`), requireAuth: true}} /> + VideoDownloadScreen} + options={{title: title(msg`Download video`)}} + /> ) } diff --git a/src/components/VideoDownloadScreen.native.tsx b/src/components/VideoDownloadScreen.native.tsx new file mode 100644 index 0000000000..a1f6466fd2 --- /dev/null +++ b/src/components/VideoDownloadScreen.native.tsx @@ -0,0 +1,4 @@ +export function VideoDownloadScreen() { + // @TODO redirect + return null +} diff --git a/src/components/VideoDownloadScreen.tsx b/src/components/VideoDownloadScreen.tsx new file mode 100644 index 0000000000..3169d265d9 --- /dev/null +++ b/src/components/VideoDownloadScreen.tsx @@ -0,0 +1,215 @@ +import React from 'react' +import {parse} from 'hls-parser' +import {MasterPlaylist, MediaPlaylist, Variant} from 'hls-parser/types' + +interface PostMessageData { + action: 'progress' | 'error' + messageStr?: string + messageFloat?: number +} + +function postMessage(data: PostMessageData) { + // @ts-expect-error safari webview only + if (window?.webkit) { + // @ts-expect-error safari webview only + window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(data)) + // @ts-expect-error android webview only + } else if (AndroidInterface) { + // @ts-expect-error android webview only + AndroidInterface.onMessage(JSON.stringify(data)) + } +} + +function createSegementUrl(originalUrl: string, newFile: string) { + const parts = originalUrl.split('/') + parts[parts.length - 1] = newFile + return parts.join('/') +} + +export function VideoDownloadScreen() { + const ffmpegRef = React.useRef(null) + const fetchFileRef = React.useRef(null) + + const [dataUrl, setDataUrl] = React.useState(null) + + const load = React.useCallback(async () => { + const ffmpegLib = await import('@ffmpeg/ffmpeg') + const ffmpeg = new ffmpegLib.FFmpeg() + ffmpegRef.current = ffmpeg + + const ffmpegUtilLib = await import('@ffmpeg/util') + fetchFileRef.current = ffmpegUtilLib.fetchFile + + const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm' + + await ffmpeg.load({ + coreURL: await ffmpegUtilLib.toBlobURL( + `${baseURL}/ffmpeg-core.js`, + 'text/javascript', + ), + wasmURL: await ffmpegUtilLib.toBlobURL( + `${baseURL}/ffmpeg-core.wasm`, + 'application/wasm', + ), + }) + }, []) + + const createMp4 = React.useCallback(async (videoUrl: string) => { + // Get the master playlist and find the best variant + const masterPlaylistRes = await fetch(videoUrl) + const masterPlaylistText = await masterPlaylistRes.text() + const masterPlaylist = parse(masterPlaylistText) as MasterPlaylist + + // If URL given is not a master playlist, we probably cannot handle this. + if (!masterPlaylist.isMasterPlaylist) { + postMessage({ + action: 'error', + messageStr: 'A master playlist was not found in the provided playlist.', + }) + return + } + + // Figure out what the best quality is. These should generally be in order, but we'll check them all just in case + let bestVariant: Variant | undefined + for (const variant of masterPlaylist.variants) { + if (!bestVariant || variant.bandwidth > bestVariant.bandwidth) { + bestVariant = variant + } + } + + // Should only happen if there was no variants at all given to us. Mostly for types. + if (!bestVariant) { + postMessage({ + action: 'error', + messageStr: 'No variants were found in the provided master playlist.', + }) + return + } + + const urlParts = videoUrl.split('/') + urlParts[urlParts.length - 1] = bestVariant?.uri + const bestVariantUrl = urlParts.join('/') + + // Download and parse m3u8 + const hlsFileRes = await fetch(bestVariantUrl) + const hlsPlainText = await hlsFileRes.text() + const playlist = parse(hlsPlainText) as MediaPlaylist + + // This one shouldn't be a master playlist - again just for types really + if (playlist.isMasterPlaylist) { + postMessage({ + action: 'error', + messageStr: 'An unknown error has occurred.', + }) + return + } + + const ffmpeg = ffmpegRef.current + + // Get the correctly ordered file names. We need to remove the tracking info from the end of the file name + const segments = playlist.segments.map(segment => { + return segment.uri.split('?')[0] + }) + + // Download each segment + let error: string | null = null + let completed = 0 + await Promise.all( + playlist.segments.map(async segment => { + const uri = createSegementUrl(bestVariantUrl, segment.uri) + const filename = segment.uri.split('?')[0] + + const res = await fetch(uri) + if (!res.ok) { + error = 'Failed to download playlist segment.' + } + + const blob = await res.blob() + try { + await ffmpeg.writeFile(filename, await fetchFileRef.current(blob)) + } catch (e: unknown) { + error = 'Failed to write file.' + } finally { + completed++ + const progress = completed / playlist.segments.length + postMessage({ + action: 'progress', + messageFloat: progress, + }) + } + }), + ) + + // Do something if there was an error + if (error) { + postMessage({ + action: 'error', + messageStr: error, + }) + return + } + + // Put the segments together + await ffmpeg.exec([ + '-i', + `concat:${segments.join('|')}`, + '-c:v', + 'copy', + 'output.mp4', + ]) + + const fileData = await ffmpeg.readFile('output.mp4') + const blob = new Blob([fileData.buffer], {type: 'video/mp4'}) + const dataUrl = await new Promise(resolve => { + const reader = new FileReader() + reader.onloadend = () => resolve(reader.result as string) + reader.onerror = () => resolve(null) + reader.readAsDataURL(blob) + }) + return dataUrl + }, []) + + const download = React.useCallback( + async (videoUrl: string) => { + await load() + const mp4Res = await createMp4(videoUrl) + + if (!mp4Res) { + postMessage({ + action: 'error', + messageStr: 'An error occurred while creating the MP4.', + }) + return + } + + setDataUrl(mp4Res) + }, + [createMp4, load], + ) + + React.useEffect(() => { + const url = new URL(window.location.href) + const videoUrl = url.searchParams.get('videoUrl') + + if (!videoUrl) { + postMessage({action: 'error', messageStr: 'No video URL provided'}) + } else { + setDataUrl(null) + download(videoUrl) + } + }, [download]) + + if (!dataUrl) return null + + return ( + + ) +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 0cc83b475a..77e7266a4f 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -50,6 +50,7 @@ export type CommonNavigatorParams = { StarterPackShort: {code: string} StarterPackWizard: undefined StarterPackEdit: {rkey?: string} + VideoDownload: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/routes.ts b/src/routes.ts index c9e23e08c8..bda2d98e4b 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -48,4 +48,5 @@ export const router = new Router({ StarterPack: '/starter-pack/:name/:rkey', StarterPackShort: '/starter-pack-short/:code', StarterPackWizard: '/starter-pack/create', + VideoDownload: '/video-download', }) diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index 71dbe8839d..c6da633145 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -1,12 +1,17 @@ import React from 'react' import {ScrollView, View} from 'react-native' +import {deleteAsync} from 'expo-file-system' +import {saveToLibraryAsync} from 'expo-media-library' import {useSetThemePrefs} from '#/state/shell' -import {isWeb} from 'platform/detection' +import {useVideoLibraryPermission} from 'lib/hooks/usePermissions' +import {isIOS, isWeb} from 'platform/detection' import {CenteredView} from '#/view/com/util/Views' +import * as Toast from 'view/com/util/Toast' import {ListContained} from 'view/screens/Storybook/ListContained' import {atoms as a, ThemeProvider, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' +import {HLSDownloadView} from '../../../../modules/expo-bluesky-swiss-army' import {Breakpoints} from './Breakpoints' import {Buttons} from './Buttons' import {Dialogs} from './Dialogs' @@ -33,10 +38,49 @@ function StorybookInner() { const t = useTheme() const {setColorMode, setDarkTheme} = useSetThemePrefs() const [showContainedList, setShowContainedList] = React.useState(false) + const hlsDownloadRef = React.useRef(null) + + const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() return ( + { + const uri = e.nativeEvent.uri + const permsRes = await requestVideoAccessIfNeeded() + if (!permsRes) return + + await saveToLibraryAsync(uri) + try { + deleteAsync(uri) + } catch (err) { + console.error('Failed to delete file', err) + } + Toast.show('Video saved to library') + }} + onStart={() => console.log('Download is starting')} + onError={e => console.log(e.nativeEvent.message)} + onProgress={e => console.log(e.nativeEvent.progress)} + /> + {!showContainedList ? ( <> diff --git a/yarn.lock b/yarn.lock index cd0508d6a6..28308d951c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3925,6 +3925,23 @@ resolved "https://registry.yarnpkg.com/@fastify/deepmerge/-/deepmerge-1.3.0.tgz#8116858108f0c7d9fd460d05a7d637a13fe3239a" integrity sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A== +"@ffmpeg/ffmpeg@^0.12.10": + version "0.12.10" + resolved "https://registry.yarnpkg.com/@ffmpeg/ffmpeg/-/ffmpeg-0.12.10.tgz#e3cce21f21f11f33dfc1ec1d5ad5694f4a3073c9" + integrity sha512-lVtk8PW8e+NUzGZhPTWj2P1J4/NyuCrbDD3O9IGpSeLYtUZKBqZO8CNj1WYGghep/MXoM8e1qVY1GztTkf8YYQ== + dependencies: + "@ffmpeg/types" "^0.12.2" + +"@ffmpeg/types@^0.12.2": + version "0.12.2" + resolved "https://registry.yarnpkg.com/@ffmpeg/types/-/types-0.12.2.tgz#bc7eef321ae50225c247091f1f23fd3087c6aa1d" + integrity sha512-NJtxwPoLb60/z1Klv0ueshguWQ/7mNm106qdHkB4HL49LXszjhjCCiL+ldHJGQ9ai2Igx0s4F24ghigy//ERdA== + +"@ffmpeg/util@^0.12.1": + version "0.12.1" + resolved "https://registry.yarnpkg.com/@ffmpeg/util/-/util-0.12.1.tgz#98afa20d7b4c0821eebdb205ddcfa5d07b0a4f53" + integrity sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ== + "@floating-ui/core@^1.0.0": version "1.6.0" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" @@ -8007,6 +8024,13 @@ resolved "https://registry.yarnpkg.com/@types/he/-/he-1.2.0.tgz#3845193e597d943bab4e61ca5d7f3d8fc3d572a3" integrity sha512-uH2smqTN4uGReAiKedIVzoLUAXIYLBTbSofhx3hbNqj74Ua6KqFsLYszduTrLCMEAEAozF73DbGi/SC1bzQq4g== +"@types/hls-parser@^0.8.7": + version "0.8.7" + resolved "https://registry.yarnpkg.com/@types/hls-parser/-/hls-parser-0.8.7.tgz#26360493231ed8606ebe995976c63c69c3982657" + integrity sha512-3ry9V6i/uhSbNdvBUENAqt2p5g+xKIbjkr5Qv4EaXe7eIJnaGQntFZalRLQlKoEop381a0LwUr2qNKKlxQC4TQ== + dependencies: + "@types/node" "*" + "@types/html-minifier-terser@^6.0.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" @@ -13456,6 +13480,11 @@ history@^5.3.0: dependencies: "@babel/runtime" "^7.7.6" +hls-parser@^0.13.3: + version "0.13.3" + resolved "https://registry.yarnpkg.com/hls-parser/-/hls-parser-0.13.3.tgz#5f7a305629cf462bbf16a4d080e03e0be714f1fe" + integrity sha512-DXqW7bwx9j2qFcAXS/LBJTDJWitxknb6oUnsnTvECHrecPvPbhRgIu45OgNDUU6gpwKxMJx40SHRRUUhdIM2gA== + hls.js@^1.5.11: version "1.5.11" resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.11.tgz#3941347df454983859ae8c75fe19e8818719a826" From f3b57dd45600c0c8197ce45a0f927b57e0799760 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 15 Aug 2024 12:03:19 -0700 Subject: [PATCH 60/67] Hack patch for testing OTA update crash behavior (#4942) --- patches/expo-modules-core+1.12.11.patch | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/patches/expo-modules-core+1.12.11.patch b/patches/expo-modules-core+1.12.11.patch index a4ee027c81..bc759f21f9 100644 --- a/patches/expo-modules-core+1.12.11.patch +++ b/patches/expo-modules-core+1.12.11.patch @@ -4,16 +4,16 @@ index bb74e80..0aa0202 100644 +++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/adapters/react/NativeModulesProxy.java @@ -90,8 +90,8 @@ public class NativeModulesProxy extends ReactContextBaseJavaModule { mModuleRegistry.ensureIsInitialized(); - + KotlinInteropModuleRegistry kotlinModuleRegistry = getKotlinInteropModuleRegistry(); - kotlinModuleRegistry.emitOnCreate(); kotlinModuleRegistry.installJSIInterop(); + kotlinModuleRegistry.emitOnCreate(); - + Map constants = new HashMap<>(3); constants.put(MODULES_CONSTANTS_KEY, new HashMap<>()); diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js -index 109d3fe..c7fce9e 100644 +index 109d3fe..c421931 100644 --- a/node_modules/expo-modules-core/build/uuid/uuid.js +++ b/node_modules/expo-modules-core/build/uuid/uuid.js @@ -1,5 +1,7 @@ @@ -24,3 +24,16 @@ index 109d3fe..c7fce9e 100644 const nativeUuidv4 = globalThis?.expo?.uuidv4; const nativeUuidv5 = globalThis?.expo?.uuidv5; function uuidv4() { +diff --git a/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift b/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift +index ee2268a..4851b67 100644 +--- a/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift ++++ b/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift +@@ -173,7 +173,7 @@ public final class SharedObjectRegistry { + } + + internal func clear() { +- Self.lockQueue.async { ++ DispatchQueue.main.sync { + self.pairs.removeAll() + } + } From b6e515c664d51ffe357c3562fd514301805ade8c Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 15 Aug 2024 20:58:13 +0100 Subject: [PATCH 61/67] Move global "Sign out" out of the current account row (#4941) * Rename logout to logoutEveryAccount * Add logoutCurrentAccount() * Make all "Log out" buttons refer to current account Each of these usages is completely contextual and refers to a specific account. * Add Sign out of all accounts to Settings * Move single account Sign Out below as well * Prompt on account removal * Add Other Accounts header to reduce ambiguity * Spacing fix --------- Co-authored-by: Paul Frazee --- src/lib/statsig/events.ts | 1 + src/screens/Deactivated.tsx | 6 +- .../components/DeactivateAccountDialog.tsx | 6 +- src/screens/SignupQueued.tsx | 6 +- src/state/session/__tests__/session-test.ts | 103 +++++++++++++++++- src/state/session/index.tsx | 38 ++++++- src/state/session/reducer.ts | 23 +++- src/state/session/types.ts | 12 +- src/view/com/testing/TestCtrls.e2e.tsx | 4 +- src/view/com/util/AccountDropdownBtn.tsx | 60 ++++++---- src/view/screens/Settings/index.tsx | 65 ++++++----- 11 files changed, 247 insertions(+), 77 deletions(-) diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 9a427ad40f..7ef0c9e2e6 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -14,6 +14,7 @@ export type LogEvents = { } 'account:loggedOut': { logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated' + scope: 'current' | 'every' } 'notifications:openApp': {} 'notifications:request': { diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx index add550f93c..997fe419ed 100644 --- a/src/screens/Deactivated.tsx +++ b/src/screens/Deactivated.tsx @@ -38,7 +38,7 @@ export function Deactivated() { const {setShowLoggedOut} = useLoggedOutViewControls() const hasOtherAccounts = accounts.length > 1 const setMinimalShellMode = useSetMinimalShellMode() - const {logout} = useSessionApi() + const {logoutCurrentAccount} = useSessionApi() const agent = useAgent() const [pending, setPending] = React.useState(false) const [error, setError] = React.useState() @@ -72,8 +72,8 @@ export function Deactivated() { // So we change the URL ourselves. The navigator will pick it up on remount. history.pushState(null, '', '/') } - logout('Deactivated') - }, [logout]) + logoutCurrentAccount('Deactivated') + }, [logoutCurrentAccount]) const handleActivate = React.useCallback(async () => { try { diff --git a/src/screens/Settings/components/DeactivateAccountDialog.tsx b/src/screens/Settings/components/DeactivateAccountDialog.tsx index 99999d068f..2be42d13e6 100644 --- a/src/screens/Settings/components/DeactivateAccountDialog.tsx +++ b/src/screens/Settings/components/DeactivateAccountDialog.tsx @@ -35,7 +35,7 @@ function DeactivateAccountDialogInner({ const {gtMobile} = useBreakpoints() const {_} = useLingui() const agent = useAgent() - const {logout} = useSessionApi() + const {logoutCurrentAccount} = useSessionApi() const [pending, setPending] = React.useState(false) const [error, setError] = React.useState() @@ -44,7 +44,7 @@ function DeactivateAccountDialogInner({ setPending(true) await agent.com.atproto.server.deactivateAccount({}) control.close(() => { - logout('Deactivated') + logoutCurrentAccount('Deactivated') }) } catch (e: any) { switch (e.message) { @@ -66,7 +66,7 @@ function DeactivateAccountDialogInner({ } finally { setPending(false) } - }, [agent, control, logout, _, setPending]) + }, [agent, control, logoutCurrentAccount, _, setPending]) return ( <> diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx index 69ef93618d..e7336569c6 100644 --- a/src/screens/SignupQueued.tsx +++ b/src/screens/SignupQueued.tsx @@ -23,7 +23,7 @@ export function SignupQueued() { const insets = useSafeAreaInsets() const {gtMobile} = useBreakpoints() const onboardingDispatch = useOnboardingDispatch() - const {logout} = useSessionApi() + const {logoutCurrentAccount} = useSessionApi() const agent = useAgent() const [isProcessing, setProcessing] = React.useState(false) @@ -153,7 +153,7 @@ export function SignupQueued() { variant="ghost" size="large" label={_(msg`Log out`)} - onPress={() => logout('SignupQueued')}> + onPress={() => logoutCurrentAccount('SignupQueued')}> Log out @@ -182,7 +182,7 @@ export function SignupQueued() { variant="ghost" size="large" label={_(msg`Log out`)} - onPress={() => logout('SignupQueued')}> + onPress={() => logoutCurrentAccount('SignupQueued')}> Log out diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index cb4c6a35bb..3e22c262cb 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -76,7 +76,7 @@ describe('session', () => { state = run(state, [ { - type: 'logged-out', + type: 'logged-out-every-account', }, ]) // Should keep the account but clear out the tokens. @@ -372,7 +372,7 @@ describe('session', () => { state = run(state, [ { // Log everyone out. - type: 'logged-out', + type: 'logged-out-every-account', }, ]) expect(state.accounts.length).toBe(3) @@ -466,7 +466,7 @@ describe('session', () => { state = run(state, [ { - type: 'logged-out', + type: 'logged-out-every-account', }, ]) expect(state.accounts.length).toBe(1) @@ -674,6 +674,103 @@ describe('session', () => { expect(state.currentAgentState.did).toBe(undefined) }) + it('can log out of the current account', () => { + let state = getInitialState([]) + + const agent1 = new BskyAgent({service: 'https://alice.com'}) + agent1.sessionManager.session = { + active: true, + did: 'alice-did', + handle: 'alice.test', + accessJwt: 'alice-access-jwt-1', + refreshJwt: 'alice-refresh-jwt-1', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent1, + newAccount: agentToSessionAccountOrThrow(agent1), + }, + ]) + expect(state.accounts.length).toBe(1) + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') + expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') + expect(state.currentAgentState.did).toBe('alice-did') + + const agent2 = new BskyAgent({service: 'https://bob.com'}) + agent2.sessionManager.session = { + active: true, + did: 'bob-did', + handle: 'bob.test', + accessJwt: 'bob-access-jwt-1', + refreshJwt: 'bob-refresh-jwt-1', + } + state = run(state, [ + { + type: 'switched-to-account', + newAgent: agent2, + newAccount: agentToSessionAccountOrThrow(agent2), + }, + ]) + expect(state.accounts.length).toBe(2) + expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1') + expect(state.accounts[0].refreshJwt).toBe('bob-refresh-jwt-1') + expect(state.currentAgentState.did).toBe('bob-did') + + state = run(state, [ + { + type: 'logged-out-current-account', + }, + ]) + expect(state.accounts.length).toBe(2) + expect(state.accounts[0].accessJwt).toBe(undefined) + expect(state.accounts[0].refreshJwt).toBe(undefined) + expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-1') + expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-1') + expect(state.currentAgentState.did).toBe(undefined) + expect(printState(state)).toMatchInlineSnapshot(` + { + "accounts": [ + { + "accessJwt": undefined, + "active": true, + "did": "bob-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "bob.test", + "pdsUrl": undefined, + "refreshJwt": undefined, + "service": "https://bob.com/", + "signupQueued": false, + "status": undefined, + }, + { + "accessJwt": "alice-access-jwt-1", + "active": true, + "did": "alice-did", + "email": undefined, + "emailAuthFactor": false, + "emailConfirmed": false, + "handle": "alice.test", + "pdsUrl": undefined, + "refreshJwt": "alice-refresh-jwt-1", + "service": "https://alice.com/", + "signupQueued": false, + "status": undefined, + }, + ], + "currentAgentState": { + "agent": { + "service": "https://public.api.bsky.app/", + }, + "did": undefined, + }, + "needsPersist": true, + } + `) + }) + it('updates stored account with refreshed tokens', () => { let state = getInitialState([]) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index ba12f4eaea..21fe7f75b9 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -35,7 +35,8 @@ const AgentContext = React.createContext(null) const ApiContext = React.createContext({ createAccount: async () => {}, login: async () => {}, - logout: async () => {}, + logoutCurrentAccount: async () => {}, + logoutEveryAccount: async () => {}, resumeSession: async () => {}, removeAccount: () => {}, }) @@ -115,14 +116,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) { [onAgentSessionChange, cancelPendingTask], ) - const logout = React.useCallback( + const logoutCurrentAccount = React.useCallback< + SessionApiContext['logoutEveryAccount'] + >( logContext => { addSessionDebugLog({type: 'method:start', method: 'logout'}) cancelPendingTask() dispatch({ - type: 'logged-out', + type: 'logged-out-current-account', }) - logEvent('account:loggedOut', {logContext}) + logEvent('account:loggedOut', {logContext, scope: 'current'}) + addSessionDebugLog({type: 'method:end', method: 'logout'}) + }, + [cancelPendingTask], + ) + + const logoutEveryAccount = React.useCallback< + SessionApiContext['logoutEveryAccount'] + >( + logContext => { + addSessionDebugLog({type: 'method:start', method: 'logout'}) + cancelPendingTask() + dispatch({ + type: 'logged-out-every-account', + }) + logEvent('account:loggedOut', {logContext, scope: 'every'}) addSessionDebugLog({type: 'method:end', method: 'logout'}) }, [cancelPendingTask], @@ -230,11 +248,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) { () => ({ createAccount, login, - logout, + logoutCurrentAccount, + logoutEveryAccount, resumeSession, removeAccount, }), - [createAccount, login, logout, resumeSession, removeAccount], + [ + createAccount, + login, + logoutCurrentAccount, + logoutEveryAccount, + resumeSession, + removeAccount, + ], ) // @ts-ignore diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts index b49198514c..22ba47162a 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -42,7 +42,10 @@ export type Action = accountDid: string } | { - type: 'logged-out' + type: 'logged-out-current-account' + } + | { + type: 'logged-out-every-account' } | { type: 'synced-accounts' @@ -138,7 +141,23 @@ let reducer = (state: State, action: Action): State => { needsPersist: true, } } - case 'logged-out': { + case 'logged-out-current-account': { + const {currentAgentState} = state + return { + accounts: state.accounts.map(a => + a.did === currentAgentState.did + ? { + ...a, + refreshJwt: undefined, + accessJwt: undefined, + } + : a, + ), + currentAgentState: createPublicAgentState(), + needsPersist: true, + } + } + case 'logged-out-every-account': { return { accounts: state.accounts.map(a => ({ ...a, diff --git a/src/state/session/types.ts b/src/state/session/types.ts index d43b57cca9..d32259de9d 100644 --- a/src/state/session/types.ts +++ b/src/state/session/types.ts @@ -29,12 +29,12 @@ export type SessionApiContext = { }, logContext: LogEvents['account:loggedIn']['logContext'], ) => Promise - /** - * A full logout. Clears the `currentAccount` from session, AND removes - * access tokens from all accounts, so that returning as any user will - * require a full login. - */ - logout: (logContext: LogEvents['account:loggedOut']['logContext']) => void + logoutCurrentAccount: ( + logContext: LogEvents['account:loggedOut']['logContext'], + ) => void + logoutEveryAccount: ( + logContext: LogEvents['account:loggedOut']['logContext'], + ) => void resumeSession: (account: SessionAccount) => Promise removeAccount: (account: SessionAccount) => void } diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx index 82750959d6..83c79ab7cd 100644 --- a/src/view/com/testing/TestCtrls.e2e.tsx +++ b/src/view/com/testing/TestCtrls.e2e.tsx @@ -20,7 +20,7 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'} export function TestCtrls() { const queryClient = useQueryClient() - const {logout, login} = useSessionApi() + const {logoutEveryAccount, login} = useSessionApi() const {openModal} = useModalControls() const onboardingDispatch = useOnboardingDispatch() const {setShowLoggedOut} = useLoggedOutViewControls() @@ -60,7 +60,7 @@ export function TestCtrls() { /> logout('Settings')} + onPress={() => logoutEveryAccount('Settings')} accessibilityRole="button" style={BTN} /> diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx index 221879df79..fa2553d384 100644 --- a/src/view/com/util/AccountDropdownBtn.tsx +++ b/src/view/com/util/AccountDropdownBtn.tsx @@ -4,26 +4,27 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' -import * as Toast from '../../com/util/Toast' -import {useSessionApi, SessionAccount} from '#/state/session' -import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {SessionAccount, useSessionApi} from '#/state/session' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' +import {useDialogControl} from '#/components/Dialog' +import * as Prompt from '#/components/Prompt' +import * as Toast from '../../com/util/Toast' +import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' export function AccountDropdownBtn({account}: {account: SessionAccount}) { const pal = usePalette('default') const {removeAccount} = useSessionApi() + const removePromptControl = useDialogControl() const {_} = useLingui() const items: DropdownItem[] = [ { label: _(msg`Remove account`), - onPress: () => { - removeAccount(account) - Toast.show(_(msg`Account removed from quick access`)) - }, + onPress: removePromptControl.open, icon: { ios: { name: 'trash', @@ -34,17 +35,32 @@ export function AccountDropdownBtn({account}: {account: SessionAccount}) { }, ] return ( - - - - - + <> + + + + + + { + removeAccount(account) + Toast.show(_(msg`Account removed from quick access`)) + }} + confirmButtonCta={_(msg`Remove`)} + confirmButtonColor="negative" + /> + ) } diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 521c2019af..fe449fcdbc 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -57,7 +57,6 @@ import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateA import {atoms as a, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' -import {navigate, resetToTab} from '#/Navigation' import {Email2FAToggle} from './Email2FAToggle' import {ExportCarDialog} from './ExportCarDialog' @@ -77,7 +76,6 @@ function SettingsAccountCard({ const {_} = useLingui() const t = useTheme() const {currentAccount} = useSession() - const {logout} = useSessionApi() const {data: profile} = useProfileQuery({did: account.did}) const isCurrentAccount = account.did === currentAccount?.did @@ -103,31 +101,7 @@ function SettingsAccountCard({ {account.handle}
- - {isCurrentAccount ? ( - { - if (isNative) { - logout('Settings') - resetToTab('HomeTab') - } else { - navigate('Home').then(() => { - logout('Settings') - }) - } - }} - accessibilityRole="button" - accessibilityLabel={_(msg`Sign out`)} - accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`} - activeOpacity={0.8}> - - Sign out - - - ) : ( - - )} +
) @@ -173,6 +147,7 @@ export function SettingsScreen({}: Props) { const {accounts, currentAccount} = useSession() const {mutate: clearPreferences} = useClearPreferencesMutation() const {setShowLoggedOut} = useLoggedOutViewControls() + const {logoutEveryAccount} = useSessionApi() const closeAllActiveElements = useCloseAllActiveElements() const exportCarControl = useDialogControl() const birthdayControl = useDialogControl() @@ -237,6 +212,10 @@ export function SettingsScreen({}: Props) { openModal({name: 'delete-account'}) }, [openModal]) + const onPressLogoutEveryAccount = React.useCallback(() => { + logoutEveryAccount('Settings') + }, [logoutEveryAccount]) + const onPressResetPreferences = React.useCallback(async () => { clearPreferences() }, [clearPreferences]) @@ -394,6 +373,15 @@ export function SettingsScreen({}: Props) { ) : null} + {accounts.length > 1 && ( + + + Other accounts + + + + )} + {accounts .filter(a => a.did !== currentAccount?.did) .map(account => ( @@ -422,6 +410,29 @@ export function SettingsScreen({}: Props) { Add account
+ + + + + + + {accounts.length > 1 ? ( + Sign out of all accounts + ) : ( + Sign out + )} + +
From a5af24b53b6085cfb5547592c29155bc10e71f9e Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 15 Aug 2024 16:29:16 -0700 Subject: [PATCH 62/67] Revert "[Video] Download videos" (#4945) --- bskyweb/cmd/bskyweb/server.go | 3 - bskyweb/static/robots.txt | 1 - .../hlsdownload/ExpoHLSDownloadModule.kt | 35 --- .../hlsdownload/HLSDownloadView.kt | 141 ------------ .../expo-module.config.json | 4 +- modules/expo-bluesky-swiss-army/index.ts | 10 +- .../HLSDownload/ExpoHLSDownloadModule.swift | 31 --- .../ios/HLSDownload/HLSDownloadView.swift | 148 ------------ .../src/HLSDownload/index.native.tsx | 39 ---- .../src/HLSDownload/index.tsx | 22 -- .../src/HLSDownload/types.ts | 10 - package.json | 4 - src/Navigation.tsx | 6 - src/components/VideoDownloadScreen.native.tsx | 4 - src/components/VideoDownloadScreen.tsx | 215 ------------------ src/lib/routes/types.ts | 1 - src/routes.ts | 1 - src/view/screens/Storybook/index.tsx | 46 +--- yarn.lock | 29 --- 19 files changed, 3 insertions(+), 747 deletions(-) delete mode 100644 modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt delete mode 100644 modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt delete mode 100644 modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift delete mode 100644 modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift delete mode 100644 modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx delete mode 100644 modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx delete mode 100644 modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts delete mode 100644 src/components/VideoDownloadScreen.native.tsx delete mode 100644 src/components/VideoDownloadScreen.tsx diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 01f1a87550..fdef01ce78 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -256,9 +256,6 @@ func serve(cctx *cli.Context) error { e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric) e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric) - // video download - e.GET("/video-download", server.WebGeneric) - // starter packs e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack) e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack) diff --git a/bskyweb/static/robots.txt b/bskyweb/static/robots.txt index d785755a43..4f8510d18d 100644 --- a/bskyweb/static/robots.txt +++ b/bskyweb/static/robots.txt @@ -7,4 +7,3 @@ # be ok. User-Agent: * Allow: / -Disallow: /video-download diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt deleted file mode 100644 index 786b84e41c..0000000000 --- a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/ExpoHLSDownloadModule.kt +++ /dev/null @@ -1,35 +0,0 @@ -package expo.modules.blueskyswissarmy.hlsdownload - -import android.net.Uri -import expo.modules.kotlin.modules.Module -import expo.modules.kotlin.modules.ModuleDefinition - -class ExpoHLSDownloadModule : Module() { - override fun definition() = - ModuleDefinition { - Name("ExpoHLSDownload") - - Function("isAvailable") { - return@Function true - } - - View(HLSDownloadView::class) { - Events( - arrayOf( - "onStart", - "onError", - "onProgress", - "onSuccess", - ), - ) - - Prop("downloaderUrl") { view: HLSDownloadView, downloaderUrl: Uri -> - view.downloaderUrl = downloaderUrl - } - - AsyncFunction("startDownloadAsync") { view: HLSDownloadView, sourceUrl: Uri -> - view.startDownload(sourceUrl) - } - } - } -} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt deleted file mode 100644 index 5f3082a819..0000000000 --- a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/hlsdownload/HLSDownloadView.kt +++ /dev/null @@ -1,141 +0,0 @@ -package expo.modules.blueskyswissarmy.hlsdownload - -import android.annotation.SuppressLint -import android.content.Context -import android.net.Uri -import android.util.Base64 -import android.util.Log -import android.webkit.DownloadListener -import android.webkit.JavascriptInterface -import android.webkit.WebView -import expo.modules.kotlin.AppContext -import expo.modules.kotlin.viewevent.EventDispatcher -import expo.modules.kotlin.viewevent.ViewEventCallback -import expo.modules.kotlin.views.ExpoView -import org.json.JSONObject -import java.io.File -import java.io.FileOutputStream -import java.net.URI -import java.util.UUID - -class HLSDownloadView( - context: Context, - appContext: AppContext, -) : ExpoView(context, appContext), - DownloadListener { - private val webView = WebView(context) - - var downloaderUrl: Uri? = null - - private val onStart by EventDispatcher() - private val onError by EventDispatcher() - private val onProgress by EventDispatcher() - private val onSuccess by EventDispatcher() - - init { - this.setupWebView() - this.addView(this.webView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) - } - - @SuppressLint("SetJavaScriptEnabled") - private fun setupWebView() { - val webSettings = this.webView.settings - webSettings.javaScriptEnabled = true - webSettings.domStorageEnabled = true - - webView.setDownloadListener(this) - webView.addJavascriptInterface(WebAppInterface(this.onProgress, this.onError), "AndroidInterface") - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - this.webView.stopLoading() - this.webView.clearHistory() - this.webView.removeAllViews() - this.webView.destroy() - } - - fun startDownload(sourceUrl: Uri) { - if (this.downloaderUrl == null) { - this.onError(mapOf(ERROR_KEY to "Downloader URL is not set.")) - return - } - - val url = URI("${this.downloaderUrl}?videoUrl=$sourceUrl") - this.webView.loadUrl(url.toString()) - this.onStart(mapOf()) - } - - override fun onDownloadStart( - url: String?, - userAgent: String?, - contentDisposition: String?, - mimeType: String?, - contentLength: Long, - ) { - if (url == null) { - this.onError(mapOf(ERROR_KEY to "Failed to retrieve download URL from webview.")) - return - } - - val tempDir = context.cacheDir - val fileName = "${UUID.randomUUID()}.mp4" - val file = File(tempDir, fileName) - - val base64 = url.split(",")[1] - val bytes = Base64.decode(base64, Base64.DEFAULT) - - val fos = FileOutputStream(file) - try { - fos.write(bytes) - } catch (e: Exception) { - Log.e("FileDownload", "Error downloading file", e) - this.onError(mapOf(ERROR_KEY to e.message.toString())) - return - } finally { - fos.close() - } - - val uri = Uri.fromFile(file) - this.onSuccess(mapOf("uri" to uri.toString())) - } - - companion object { - const val ERROR_KEY = "message" - } -} - -public class WebAppInterface( - val onProgress: ViewEventCallback>, - val onError: ViewEventCallback>, -) { - @JavascriptInterface - public fun onMessage(message: String) { - val jsonObject = JSONObject(message) - val action = jsonObject.getString("action") - - when (action) { - "error" -> { - val messageStr = jsonObject.get("messageStr") - if (messageStr !is String) { - this.onError(mapOf(ERROR_KEY to "Failed to decode JSON post message.")) - return - } - this.onError(mapOf(ERROR_KEY to messageStr)) - } - "progress" -> { - val messageFloat = jsonObject.get("messageFloat") - if (messageFloat !is Number) { - this.onError(mapOf(ERROR_KEY to "Failed to decode JSON post message.")) - return - } - this.onProgress(mapOf(PROGRESS_KEY to messageFloat)) - } - } - } - - companion object { - const val PROGRESS_KEY = "progress" - const val ERROR_KEY = "message" - } -} diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json index 04411ecf7e..4cdc11e993 100644 --- a/modules/expo-bluesky-swiss-army/expo-module.config.json +++ b/modules/expo-bluesky-swiss-army/expo-module.config.json @@ -5,7 +5,6 @@ "ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoBlueskyVisibilityViewModule", - "ExpoHLSDownloadModule", "ExpoPlatformInfoModule" ] }, @@ -14,8 +13,7 @@ "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule", "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule", "expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule", - "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule", - "expo.modules.blueskyswissarmy.hlsdownload.ExpoHLSDownloadModule" + "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule" ] } } diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts index 67dc6ee608..2cf4f36c52 100644 --- a/modules/expo-bluesky-swiss-army/index.ts +++ b/modules/expo-bluesky-swiss-army/index.ts @@ -1,15 +1,7 @@ -import HLSDownloadView from './src/HLSDownload' import * as PlatformInfo from './src/PlatformInfo' import {AudioCategory} from './src/PlatformInfo/types' import * as Referrer from './src/Referrer' import * as SharedPrefs from './src/SharedPrefs' import VisibilityView from './src/VisibilityView' -export { - AudioCategory, - HLSDownloadView, - PlatformInfo, - Referrer, - SharedPrefs, - VisibilityView, -} +export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView} diff --git a/modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift b/modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift deleted file mode 100644 index a9b445e489..0000000000 --- a/modules/expo-bluesky-swiss-army/ios/HLSDownload/ExpoHLSDownloadModule.swift +++ /dev/null @@ -1,31 +0,0 @@ -import ExpoModulesCore - -public class ExpoHLSDownloadModule: Module { - public func definition() -> ModuleDefinition { - Name("ExpoHLSDownload") - - Function("isAvailable") { - if #available(iOS 14.5, *) { - return true - } - return false - } - - View(HLSDownloadView.self) { - Events([ - "onStart", - "onError", - "onProgress", - "onSuccess" - ]) - - Prop("downloaderUrl") { (view: HLSDownloadView, downloaderUrl: URL) in - view.downloaderUrl = downloaderUrl - } - - AsyncFunction("startDownloadAsync") { (view: HLSDownloadView, sourceUrl: URL) in - view.startDownload(sourceUrl: sourceUrl) - } - } - } -} diff --git a/modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift b/modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift deleted file mode 100644 index 591c09335b..0000000000 --- a/modules/expo-bluesky-swiss-army/ios/HLSDownload/HLSDownloadView.swift +++ /dev/null @@ -1,148 +0,0 @@ -import ExpoModulesCore -import WebKit - -class HLSDownloadView: ExpoView, WKScriptMessageHandler, WKNavigationDelegate, WKDownloadDelegate { - var webView: WKWebView! - var downloaderUrl: URL? - - private var onStart = EventDispatcher() - private var onError = EventDispatcher() - private var onProgress = EventDispatcher() - private var onSuccess = EventDispatcher() - - private var outputUrl: URL? - - public required init(appContext: AppContext? = nil) { - super.init(appContext: appContext) - - // controller for post message api - let contentController = WKUserContentController() - contentController.add(self, name: "onMessage") - let configuration = WKWebViewConfiguration() - configuration.userContentController = contentController - - // create webview - let webView = WKWebView(frame: .zero, configuration: configuration) - - // Use these for debugging, to see the webview itself - webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - webView.layer.masksToBounds = false - webView.backgroundColor = .clear - webView.contentMode = .scaleToFill - - webView.navigationDelegate = self - - self.addSubview(webView) - self.webView = webView - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - view functions - - func startDownload(sourceUrl: URL) { - guard let downloaderUrl = self.downloaderUrl, - let url = URL(string: "\(downloaderUrl.absoluteString)?videoUrl=\(sourceUrl.absoluteString)") else { - self.onError([ - "message": "Downloader URL is not set." - ]) - return - } - - self.onStart() - self.webView.load(URLRequest(url: url)) - } - - // webview message handling - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - guard let response = message.body as? String, - let data = response.data(using: .utf8), - let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else { - self.onError([ - "message": "Failed to decode JSON post message." - ]) - return - } - - switch payload.action { - case .progress: - guard let progress = payload.messageFloat else { - self.onError([ - "message": "Failed to decode JSON post message." - ]) - return - } - self.onProgress([ - "progress": progress - ]) - case .error: - guard let messageStr = payload.messageStr else { - self.onError([ - "message": "Failed to decode JSON post message." - ]) - return - } - self.onError([ - "message": messageStr - ]) - } - } - - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { - guard #available(iOS 14.5, *) else { - return .cancel - } - - if navigationAction.shouldPerformDownload { - return .download - } else { - return .allow - } - } - - // MARK: - wkdownloaddelegate - - @available(iOS 14.5, *) - func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { - download.delegate = self - } - - @available(iOS 14.5, *) - func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { - download.delegate = self - } - - @available(iOS 14.5, *) - func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { - let directory = NSTemporaryDirectory() - let fileName = "\(NSUUID().uuidString).mp4" - let url = NSURL.fileURL(withPathComponents: [directory, fileName]) - - self.outputUrl = url - completionHandler(url) - } - - @available(iOS 14.5, *) - func downloadDidFinish(_ download: WKDownload) { - guard let url = self.outputUrl else { - return - } - self.onSuccess([ - "uri": url.absoluteString - ]) - self.outputUrl = nil - } -} - -struct WebViewActionPayload: Decodable { - enum Action: String, Decodable { - case progress, error - } - - let action: Action - let messageStr: String? - let messageFloat: Float? -} diff --git a/modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx deleted file mode 100644 index 92f26192e5..0000000000 --- a/modules/expo-bluesky-swiss-army/src/HLSDownload/index.native.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react' -import {StyleProp, ViewStyle} from 'react-native' -import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' - -import {HLSDownloadViewProps} from './types' - -const NativeModule = requireNativeModule('ExpoHLSDownload') -const NativeView: React.ComponentType< - HLSDownloadViewProps & { - ref: React.RefObject - style: StyleProp - } -> = requireNativeViewManager('ExpoHLSDownload') - -export default class HLSDownloadView extends React.PureComponent { - private nativeRef: React.RefObject = React.createRef() - - constructor(props: HLSDownloadViewProps) { - super(props) - } - - static isAvailable(): boolean { - return NativeModule.isAvailable() - } - - async startDownloadAsync(sourceUrl: string): Promise { - return await this.nativeRef.current.startDownloadAsync(sourceUrl) - } - - render() { - return ( - - ) - } -} diff --git a/modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx b/modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx deleted file mode 100644 index 93c50497fa..0000000000 --- a/modules/expo-bluesky-swiss-army/src/HLSDownload/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react' - -import {NotImplementedError} from '../NotImplemented' -import {HLSDownloadViewProps} from './types' - -export default class HLSDownloadView extends React.PureComponent { - constructor(props: HLSDownloadViewProps) { - super(props) - } - - static isAvailable(): boolean { - return false - } - - async startDownloadAsync(sourceUrl: string): Promise { - throw new NotImplementedError({sourceUrl}) - } - - render() { - return null - } -} diff --git a/modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts b/modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts deleted file mode 100644 index 6a474d2820..0000000000 --- a/modules/expo-bluesky-swiss-army/src/HLSDownload/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {NativeSyntheticEvent} from 'react-native' - -export interface HLSDownloadViewProps { - downloaderUrl: string - onSuccess: (e: NativeSyntheticEvent<{uri: string}>) => void - - onStart?: () => void - onError?: (e: NativeSyntheticEvent<{message: string}>) => void - onProgress?: (e: NativeSyntheticEvent<{progress: number}>) => void -} diff --git a/package.json b/package.json index 088f2faf76..a4523d988f 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,6 @@ "@emoji-mart/react": "^1.1.1", "@expo/html-elements": "^0.4.2", "@expo/webpack-config": "^19.0.0", - "@ffmpeg/ffmpeg": "^0.12.10", - "@ffmpeg/util": "^0.12.1", "@floating-ui/dom": "^1.6.3", "@floating-ui/react-dom": "^2.0.8", "@formatjs/intl-locale": "^4.0.0", @@ -145,7 +143,6 @@ "expo-web-browser": "~13.0.3", "fast-text-encoding": "^1.0.6", "history": "^5.3.0", - "hls-parser": "^0.13.3", "hls.js": "^1.5.11", "js-sha256": "^0.9.0", "jwt-decode": "^4.0.0", @@ -227,7 +224,6 @@ "@testing-library/react-native": "^11.5.2", "@tsconfig/react-native": "^2.0.3", "@types/he": "^1.1.2", - "@types/hls-parser": "^0.8.7", "@types/jest": "^29.4.0", "@types/lodash.chunk": "^4.2.7", "@types/lodash.debounce": "^4.0.7", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 0d151427fb..79856879c3 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -50,7 +50,6 @@ import { StarterPackScreenShort, } from '#/screens/StarterPack/StarterPackScreen' import {Wizard} from '#/screens/StarterPack/Wizard' -import {VideoDownloadScreen} from '#/components/VideoDownloadScreen' import {Referrer} from '../modules/expo-bluesky-swiss-army' import {init as initAnalytics} from './lib/analytics/analytics' import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' @@ -365,11 +364,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => Wizard} options={{title: title(msg`Edit your starter pack`), requireAuth: true}} /> - VideoDownloadScreen} - options={{title: title(msg`Download video`)}} - /> ) } diff --git a/src/components/VideoDownloadScreen.native.tsx b/src/components/VideoDownloadScreen.native.tsx deleted file mode 100644 index a1f6466fd2..0000000000 --- a/src/components/VideoDownloadScreen.native.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export function VideoDownloadScreen() { - // @TODO redirect - return null -} diff --git a/src/components/VideoDownloadScreen.tsx b/src/components/VideoDownloadScreen.tsx deleted file mode 100644 index 3169d265d9..0000000000 --- a/src/components/VideoDownloadScreen.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import React from 'react' -import {parse} from 'hls-parser' -import {MasterPlaylist, MediaPlaylist, Variant} from 'hls-parser/types' - -interface PostMessageData { - action: 'progress' | 'error' - messageStr?: string - messageFloat?: number -} - -function postMessage(data: PostMessageData) { - // @ts-expect-error safari webview only - if (window?.webkit) { - // @ts-expect-error safari webview only - window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(data)) - // @ts-expect-error android webview only - } else if (AndroidInterface) { - // @ts-expect-error android webview only - AndroidInterface.onMessage(JSON.stringify(data)) - } -} - -function createSegementUrl(originalUrl: string, newFile: string) { - const parts = originalUrl.split('/') - parts[parts.length - 1] = newFile - return parts.join('/') -} - -export function VideoDownloadScreen() { - const ffmpegRef = React.useRef(null) - const fetchFileRef = React.useRef(null) - - const [dataUrl, setDataUrl] = React.useState(null) - - const load = React.useCallback(async () => { - const ffmpegLib = await import('@ffmpeg/ffmpeg') - const ffmpeg = new ffmpegLib.FFmpeg() - ffmpegRef.current = ffmpeg - - const ffmpegUtilLib = await import('@ffmpeg/util') - fetchFileRef.current = ffmpegUtilLib.fetchFile - - const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm' - - await ffmpeg.load({ - coreURL: await ffmpegUtilLib.toBlobURL( - `${baseURL}/ffmpeg-core.js`, - 'text/javascript', - ), - wasmURL: await ffmpegUtilLib.toBlobURL( - `${baseURL}/ffmpeg-core.wasm`, - 'application/wasm', - ), - }) - }, []) - - const createMp4 = React.useCallback(async (videoUrl: string) => { - // Get the master playlist and find the best variant - const masterPlaylistRes = await fetch(videoUrl) - const masterPlaylistText = await masterPlaylistRes.text() - const masterPlaylist = parse(masterPlaylistText) as MasterPlaylist - - // If URL given is not a master playlist, we probably cannot handle this. - if (!masterPlaylist.isMasterPlaylist) { - postMessage({ - action: 'error', - messageStr: 'A master playlist was not found in the provided playlist.', - }) - return - } - - // Figure out what the best quality is. These should generally be in order, but we'll check them all just in case - let bestVariant: Variant | undefined - for (const variant of masterPlaylist.variants) { - if (!bestVariant || variant.bandwidth > bestVariant.bandwidth) { - bestVariant = variant - } - } - - // Should only happen if there was no variants at all given to us. Mostly for types. - if (!bestVariant) { - postMessage({ - action: 'error', - messageStr: 'No variants were found in the provided master playlist.', - }) - return - } - - const urlParts = videoUrl.split('/') - urlParts[urlParts.length - 1] = bestVariant?.uri - const bestVariantUrl = urlParts.join('/') - - // Download and parse m3u8 - const hlsFileRes = await fetch(bestVariantUrl) - const hlsPlainText = await hlsFileRes.text() - const playlist = parse(hlsPlainText) as MediaPlaylist - - // This one shouldn't be a master playlist - again just for types really - if (playlist.isMasterPlaylist) { - postMessage({ - action: 'error', - messageStr: 'An unknown error has occurred.', - }) - return - } - - const ffmpeg = ffmpegRef.current - - // Get the correctly ordered file names. We need to remove the tracking info from the end of the file name - const segments = playlist.segments.map(segment => { - return segment.uri.split('?')[0] - }) - - // Download each segment - let error: string | null = null - let completed = 0 - await Promise.all( - playlist.segments.map(async segment => { - const uri = createSegementUrl(bestVariantUrl, segment.uri) - const filename = segment.uri.split('?')[0] - - const res = await fetch(uri) - if (!res.ok) { - error = 'Failed to download playlist segment.' - } - - const blob = await res.blob() - try { - await ffmpeg.writeFile(filename, await fetchFileRef.current(blob)) - } catch (e: unknown) { - error = 'Failed to write file.' - } finally { - completed++ - const progress = completed / playlist.segments.length - postMessage({ - action: 'progress', - messageFloat: progress, - }) - } - }), - ) - - // Do something if there was an error - if (error) { - postMessage({ - action: 'error', - messageStr: error, - }) - return - } - - // Put the segments together - await ffmpeg.exec([ - '-i', - `concat:${segments.join('|')}`, - '-c:v', - 'copy', - 'output.mp4', - ]) - - const fileData = await ffmpeg.readFile('output.mp4') - const blob = new Blob([fileData.buffer], {type: 'video/mp4'}) - const dataUrl = await new Promise(resolve => { - const reader = new FileReader() - reader.onloadend = () => resolve(reader.result as string) - reader.onerror = () => resolve(null) - reader.readAsDataURL(blob) - }) - return dataUrl - }, []) - - const download = React.useCallback( - async (videoUrl: string) => { - await load() - const mp4Res = await createMp4(videoUrl) - - if (!mp4Res) { - postMessage({ - action: 'error', - messageStr: 'An error occurred while creating the MP4.', - }) - return - } - - setDataUrl(mp4Res) - }, - [createMp4, load], - ) - - React.useEffect(() => { - const url = new URL(window.location.href) - const videoUrl = url.searchParams.get('videoUrl') - - if (!videoUrl) { - postMessage({action: 'error', messageStr: 'No video URL provided'}) - } else { - setDataUrl(null) - download(videoUrl) - } - }, [download]) - - if (!dataUrl) return null - - return ( - - ) -} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 77e7266a4f..0cc83b475a 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -50,7 +50,6 @@ export type CommonNavigatorParams = { StarterPackShort: {code: string} StarterPackWizard: undefined StarterPackEdit: {rkey?: string} - VideoDownload: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/routes.ts b/src/routes.ts index bda2d98e4b..c9e23e08c8 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -48,5 +48,4 @@ export const router = new Router({ StarterPack: '/starter-pack/:name/:rkey', StarterPackShort: '/starter-pack-short/:code', StarterPackWizard: '/starter-pack/create', - VideoDownload: '/video-download', }) diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index c6da633145..71dbe8839d 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -1,17 +1,12 @@ import React from 'react' import {ScrollView, View} from 'react-native' -import {deleteAsync} from 'expo-file-system' -import {saveToLibraryAsync} from 'expo-media-library' import {useSetThemePrefs} from '#/state/shell' -import {useVideoLibraryPermission} from 'lib/hooks/usePermissions' -import {isIOS, isWeb} from 'platform/detection' +import {isWeb} from 'platform/detection' import {CenteredView} from '#/view/com/util/Views' -import * as Toast from 'view/com/util/Toast' import {ListContained} from 'view/screens/Storybook/ListContained' import {atoms as a, ThemeProvider, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' -import {HLSDownloadView} from '../../../../modules/expo-bluesky-swiss-army' import {Breakpoints} from './Breakpoints' import {Buttons} from './Buttons' import {Dialogs} from './Dialogs' @@ -38,49 +33,10 @@ function StorybookInner() { const t = useTheme() const {setColorMode, setDarkTheme} = useSetThemePrefs() const [showContainedList, setShowContainedList] = React.useState(false) - const hlsDownloadRef = React.useRef(null) - - const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() return ( - { - const uri = e.nativeEvent.uri - const permsRes = await requestVideoAccessIfNeeded() - if (!permsRes) return - - await saveToLibraryAsync(uri) - try { - deleteAsync(uri) - } catch (err) { - console.error('Failed to delete file', err) - } - Toast.show('Video saved to library') - }} - onStart={() => console.log('Download is starting')} - onError={e => console.log(e.nativeEvent.message)} - onProgress={e => console.log(e.nativeEvent.progress)} - /> - {!showContainedList ? ( <> diff --git a/yarn.lock b/yarn.lock index 28308d951c..cd0508d6a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3925,23 +3925,6 @@ resolved "https://registry.yarnpkg.com/@fastify/deepmerge/-/deepmerge-1.3.0.tgz#8116858108f0c7d9fd460d05a7d637a13fe3239a" integrity sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A== -"@ffmpeg/ffmpeg@^0.12.10": - version "0.12.10" - resolved "https://registry.yarnpkg.com/@ffmpeg/ffmpeg/-/ffmpeg-0.12.10.tgz#e3cce21f21f11f33dfc1ec1d5ad5694f4a3073c9" - integrity sha512-lVtk8PW8e+NUzGZhPTWj2P1J4/NyuCrbDD3O9IGpSeLYtUZKBqZO8CNj1WYGghep/MXoM8e1qVY1GztTkf8YYQ== - dependencies: - "@ffmpeg/types" "^0.12.2" - -"@ffmpeg/types@^0.12.2": - version "0.12.2" - resolved "https://registry.yarnpkg.com/@ffmpeg/types/-/types-0.12.2.tgz#bc7eef321ae50225c247091f1f23fd3087c6aa1d" - integrity sha512-NJtxwPoLb60/z1Klv0ueshguWQ/7mNm106qdHkB4HL49LXszjhjCCiL+ldHJGQ9ai2Igx0s4F24ghigy//ERdA== - -"@ffmpeg/util@^0.12.1": - version "0.12.1" - resolved "https://registry.yarnpkg.com/@ffmpeg/util/-/util-0.12.1.tgz#98afa20d7b4c0821eebdb205ddcfa5d07b0a4f53" - integrity sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ== - "@floating-ui/core@^1.0.0": version "1.6.0" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" @@ -8024,13 +8007,6 @@ resolved "https://registry.yarnpkg.com/@types/he/-/he-1.2.0.tgz#3845193e597d943bab4e61ca5d7f3d8fc3d572a3" integrity sha512-uH2smqTN4uGReAiKedIVzoLUAXIYLBTbSofhx3hbNqj74Ua6KqFsLYszduTrLCMEAEAozF73DbGi/SC1bzQq4g== -"@types/hls-parser@^0.8.7": - version "0.8.7" - resolved "https://registry.yarnpkg.com/@types/hls-parser/-/hls-parser-0.8.7.tgz#26360493231ed8606ebe995976c63c69c3982657" - integrity sha512-3ry9V6i/uhSbNdvBUENAqt2p5g+xKIbjkr5Qv4EaXe7eIJnaGQntFZalRLQlKoEop381a0LwUr2qNKKlxQC4TQ== - dependencies: - "@types/node" "*" - "@types/html-minifier-terser@^6.0.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" @@ -13480,11 +13456,6 @@ history@^5.3.0: dependencies: "@babel/runtime" "^7.7.6" -hls-parser@^0.13.3: - version "0.13.3" - resolved "https://registry.yarnpkg.com/hls-parser/-/hls-parser-0.13.3.tgz#5f7a305629cf462bbf16a4d080e03e0be714f1fe" - integrity sha512-DXqW7bwx9j2qFcAXS/LBJTDJWitxknb6oUnsnTvECHrecPvPbhRgIu45OgNDUU6gpwKxMJx40SHRRUUhdIM2gA== - hls.js@^1.5.11: version "1.5.11" resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.11.tgz#3941347df454983859ae8c75fe19e8818719a826" From 40ab67fc4b5632715f9f0a003bbd243aa81668f3 Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 16 Aug 2024 20:06:55 +0100 Subject: [PATCH 63/67] [Experiment] Always show bottom bar (#4946) --- src/lib/hooks/useMinimalShellTransform.ts | 18 ++++++++++++++++++ src/lib/statsig/gates.ts | 1 + src/view/screens/Home.tsx | 8 +++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/lib/hooks/useMinimalShellTransform.ts b/src/lib/hooks/useMinimalShellTransform.ts index 9875840d65..17fe058e9b 100644 --- a/src/lib/hooks/useMinimalShellTransform.ts +++ b/src/lib/hooks/useMinimalShellTransform.ts @@ -2,6 +2,7 @@ import {interpolate, useAnimatedStyle} from 'react-native-reanimated' import {useMinimalShellMode} from '#/state/shell/minimal-mode' import {useShellLayout} from '#/state/shell/shell-layout' +import {useGate} from '../statsig/statsig' // Keep these separated so that we only pay for useAnimatedStyle that gets used. @@ -27,8 +28,13 @@ export function useMinimalShellHeaderTransform() { export function useMinimalShellFooterTransform() { const mode = useMinimalShellMode() const {footerHeight} = useShellLayout() + const gate = useGate() + const isFixedBottomBar = gate('fixed_bottom_bar') const footerTransform = useAnimatedStyle(() => { + if (isFixedBottomBar) { + return {} + } return { pointerEvents: mode.value === 0 ? 'auto' : 'none', opacity: Math.pow(1 - mode.value, 2), @@ -39,13 +45,25 @@ export function useMinimalShellFooterTransform() { ], } }) + return footerTransform } export function useMinimalShellFabTransform() { const mode = useMinimalShellMode() + const gate = useGate() + const isFixedBottomBar = gate('fixed_bottom_bar') const fabTransform = useAnimatedStyle(() => { + if (isFixedBottomBar) { + return { + transform: [ + { + translateY: -44, + }, + ], + } + } return { transform: [ { diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 492d09e95f..0f92cd14a2 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,6 +1,7 @@ export type Gate = // Keep this alphabetic please. | 'debug_show_feedcontext' + | 'fixed_bottom_bar' | 'new_user_guided_tour' | 'onboarding_minimum_interests' | 'show_follow_back_label_v2' diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 6ee8b3ada6..9a47007c4b 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -7,6 +7,7 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useSetTitle} from '#/lib/hooks/useSetTitle' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {logEvent, LogEvents} from '#/lib/statsig/statsig' +import {useGate} from '#/lib/statsig/statsig' import {emitSoftReset} from '#/state/events' import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed' import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' @@ -88,6 +89,7 @@ function HomeScreenReady({ const selectedFeed = allFeeds[selectedIndex] const requestNotificationsPermission = useRequestNotificationsPermission() const triggerTourIfQueued = useTriggerTourIfQueued(TOURS.HOME) + const gate = useGate() useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName) useOTAUpdates() @@ -169,6 +171,10 @@ function HomeScreenReady({ const {isMobile} = useWebMediaQueries() useFocusEffect( React.useCallback(() => { + if (gate('fixed_bottom_bar')) { + // Unnecessary because it's always there. + return + } const listener = AppState.addEventListener('change', nextAppState => { if (nextAppState === 'active') { if (isMobile && mode.value === 1) { @@ -181,7 +187,7 @@ function HomeScreenReady({ return () => { listener.remove() } - }, [setMinimalShellMode, mode, isMobile]), + }, [setMinimalShellMode, mode, isMobile, gate]), ) const onPageSelected = React.useCallback( From 2939ee7df751eef4c3e673e321c6b900847d43d9 Mon Sep 17 00:00:00 2001 From: Hailey Date: Sun, 18 Aug 2024 13:24:41 -0700 Subject: [PATCH 64/67] Tweak `expo-modules-core` hack patch (#4955) --- patches/expo-modules-core+1.12.11.patch | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/patches/expo-modules-core+1.12.11.patch b/patches/expo-modules-core+1.12.11.patch index bc759f21f9..4bfecb3880 100644 --- a/patches/expo-modules-core+1.12.11.patch +++ b/patches/expo-modules-core+1.12.11.patch @@ -4,12 +4,12 @@ index bb74e80..0aa0202 100644 +++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/adapters/react/NativeModulesProxy.java @@ -90,8 +90,8 @@ public class NativeModulesProxy extends ReactContextBaseJavaModule { mModuleRegistry.ensureIsInitialized(); - + KotlinInteropModuleRegistry kotlinModuleRegistry = getKotlinInteropModuleRegistry(); - kotlinModuleRegistry.emitOnCreate(); kotlinModuleRegistry.installJSIInterop(); + kotlinModuleRegistry.emitOnCreate(); - + Map constants = new HashMap<>(3); constants.put(MODULES_CONSTANTS_KEY, new HashMap<>()); diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js @@ -30,10 +30,10 @@ index ee2268a..4851b67 100644 +++ b/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift @@ -173,7 +173,7 @@ public final class SharedObjectRegistry { } - + internal func clear() { - Self.lockQueue.async { -+ DispatchQueue.main.sync { ++ Self.lockQueue.sync { self.pairs.removeAll() } } From 3976d6738b30e36c563ae3271768334edede0d5d Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 19 Aug 2024 11:20:42 -0500 Subject: [PATCH 65/67] Fix orphaned feed slices, handle blocks (#4944) * Fix orphaned feed slices, handle blocks * Revert to filerting out orphan threads * Support NotFoundPost views too * Just kidding, use ReplyRef.root as source of grandparent data * Fixes --- src/lib/api/feed-manip.ts | 31 ++++++++++++++++++++++++++----- src/state/queries/post-feed.ts | 2 ++ src/view/com/posts/FeedItem.tsx | 19 ++++++++++++++++--- src/view/com/posts/FeedSlice.tsx | 4 ++++ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 61de795a14..c2b80ca042 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -23,6 +23,7 @@ type FeedSliceItem = { record: AppBskyFeedPost.Record parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined isParentBlocked: boolean + isParentNotFound: boolean } type AuthorContext = { @@ -68,6 +69,7 @@ export class FeedViewPostsSlice { } const parent = reply?.parent const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent) + const isParentNotFound = AppBskyFeedDefs.isNotFoundPost(parent) let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined if (AppBskyFeedDefs.isPostView(parent)) { parentAuthor = parent.author @@ -77,6 +79,7 @@ export class FeedViewPostsSlice { record: post.record, parentAuthor, isParentBlocked, + isParentNotFound, }) if (!reply || reason) { return @@ -89,23 +92,40 @@ export class FeedViewPostsSlice { this.isOrphan = true return } + const root = reply.root + const rootIsView = + AppBskyFeedDefs.isPostView(root) || + AppBskyFeedDefs.isBlockedPost(root) || + AppBskyFeedDefs.isNotFoundPost(root) + /* + * If the parent is also the root, we just so happen to have the data we + * need to compute if the parent's parent (grandparent) is blocked. This + * doesn't always happen, of course, but we can take advantage of it when + * it does. + */ + const grandparent = + rootIsView && parent.record.reply?.parent.uri === root.uri + ? root + : undefined const grandparentAuthor = reply.grandparentAuthor const isGrandparentBlocked = Boolean( - grandparentAuthor?.viewer?.blockedBy || - grandparentAuthor?.viewer?.blocking || - grandparentAuthor?.viewer?.blockingByList, + grandparent && AppBskyFeedDefs.isBlockedPost(grandparent), + ) + const isGrandparentNotFound = Boolean( + grandparent && AppBskyFeedDefs.isNotFoundPost(grandparent), ) this.items.unshift({ post: parent, record: parent.record, parentAuthor: grandparentAuthor, isParentBlocked: isGrandparentBlocked, + isParentNotFound: isGrandparentNotFound, }) if (isGrandparentBlocked) { this.isOrphan = true - // Keep going, it might still have a root. + // Keep going, it might still have a root, and we need this for thread + // de-deduping } - const root = reply.root if ( !AppBskyFeedDefs.isPostView(root) || !AppBskyFeedPost.isRecord(root.record) || @@ -121,6 +141,7 @@ export class FeedViewPostsSlice { post: root, record: root.record, isParentBlocked: false, + isParentNotFound: false, parentAuthor: undefined, }) if (parent.record.reply?.parent.uri !== root.uri) { diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 724043e586..ee3e2c14d2 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -80,6 +80,7 @@ export interface FeedPostSliceItem { moderation: ModerationDecision parentAuthor?: AppBskyActorDefs.ProfileViewBasic isParentBlocked?: boolean + isParentNotFound?: boolean } export interface FeedPostSlice { @@ -326,6 +327,7 @@ export function usePostFeedQuery( moderation: moderations[i], parentAuthor: item.parentAuthor, isParentBlocked: item.isParentBlocked, + isParentNotFound: item.isParentNotFound, } return feedPostSliceItem }), diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 0071e2401b..0fef4c5a83 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -63,6 +63,7 @@ interface FeedItemProps { feedContext: string | undefined hideTopBorder?: boolean isParentBlocked?: boolean + isParentNotFound?: boolean } export function FeedItem({ @@ -78,6 +79,7 @@ export function FeedItem({ isThreadParent, hideTopBorder, isParentBlocked, + isParentNotFound, }: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode { const postShadowed = usePostShadow(post) const richText = useMemo( @@ -109,6 +111,7 @@ export function FeedItem({ isThreadParent={isThreadParent} hideTopBorder={hideTopBorder} isParentBlocked={isParentBlocked} + isParentNotFound={isParentNotFound} /> ) } @@ -129,6 +132,7 @@ let FeedItemInner = ({ isThreadParent, hideTopBorder, isParentBlocked, + isParentNotFound, }: FeedItemProps & { richText: RichTextAPI post: Shadow @@ -344,9 +348,14 @@ let FeedItemInner = ({ postHref={href} onOpenAuthor={onOpenAuthor} /> - {showReplyTo && (parentAuthor || isParentBlocked) && ( - - )} + {showReplyTo && + (parentAuthor || isParentBlocked || isParentNotFound) && ( + + )} Reply to a blocked post + } else if (notFound) { + label = Reply to an unknown post } else if (profile != null) { const isMe = profile.did === currentAccount?.did if (isMe) { diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index fcd1ec3b18..9676eff1f6 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -36,6 +36,7 @@ let FeedSlice = ({ isThreadChild={isThreadChildAt(slice.items, 0)} hideTopBorder={hideTopBorder} isParentBlocked={slice.items[0].isParentBlocked} + isParentNotFound={slice.items[0].isParentNotFound} /> @@ -90,6 +93,7 @@ let FeedSlice = ({ isThreadChildAt(slice.items, i) && slice.items.length === i + 1 } isParentBlocked={slice.items[i].isParentBlocked} + isParentNotFound={slice.items[i].isParentNotFound} hideTopBorder={hideTopBorder && i === 0} /> ))} From f235be9819286c38e7c76142a62d39e22a7746d1 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 19 Aug 2024 13:27:04 -0500 Subject: [PATCH 66/67] Expose more props from button (#4953) --- src/components/Button.tsx | 80 +++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 7881fc9b5e..d65444e1f7 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,6 +1,8 @@ import React from 'react' import { AccessibilityProps, + GestureResponderEvent, + MouseEvent, Pressable, PressableProps, StyleProp, @@ -65,7 +67,15 @@ type NonTextElements = export type ButtonProps = Pick< PressableProps, - 'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop' + | 'disabled' + | 'onPress' + | 'testID' + | 'onLongPress' + | 'hitSlop' + | 'onHoverIn' + | 'onHoverOut' + | 'onPressIn' + | 'onPressOut' > & AccessibilityProps & VariantProps & { @@ -115,30 +125,50 @@ export const Button = React.forwardRef( focused: false, }) - const onPressIn = React.useCallback(() => { - setState(s => ({ - ...s, - pressed: true, - })) - }, [setState]) - const onPressOut = React.useCallback(() => { - setState(s => ({ - ...s, - pressed: false, - })) - }, [setState]) - const onHoverIn = React.useCallback(() => { - setState(s => ({ - ...s, - hovered: true, - })) - }, [setState]) - const onHoverOut = React.useCallback(() => { - setState(s => ({ - ...s, - hovered: false, - })) - }, [setState]) + const onPressInOuter = rest.onPressIn + const onPressIn = React.useCallback( + (e: GestureResponderEvent) => { + setState(s => ({ + ...s, + pressed: true, + })) + onPressInOuter?.(e) + }, + [setState, onPressInOuter], + ) + const onPressOutOuter = rest.onPressOut + const onPressOut = React.useCallback( + (e: GestureResponderEvent) => { + setState(s => ({ + ...s, + pressed: false, + })) + onPressOutOuter?.(e) + }, + [setState, onPressOutOuter], + ) + const onHoverInOuter = rest.onHoverIn + const onHoverIn = React.useCallback( + (e: MouseEvent) => { + setState(s => ({ + ...s, + hovered: true, + })) + onHoverInOuter?.(e) + }, + [setState, onHoverInOuter], + ) + const onHoverOutOuter = rest.onHoverOut + const onHoverOut = React.useCallback( + (e: MouseEvent) => { + setState(s => ({ + ...s, + hovered: false, + })) + onHoverOutOuter?.(e) + }, + [setState, onHoverOutOuter], + ) const onFocus = React.useCallback(() => { setState(s => ({ ...s, From e54298ec2c9a04aabe40ee7719962e2e33be23ec Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 19 Aug 2024 14:21:29 -0500 Subject: [PATCH 67/67] Expose more methods, support disabled items (#4954) --- src/components/Menu/context.tsx | 6 +++- src/components/Menu/index.tsx | 34 ++++++++++++++++++----- src/components/Menu/index.web.tsx | 46 +++++++++++++++++++++---------- src/components/Menu/types.ts | 10 +++++-- 4 files changed, 71 insertions(+), 25 deletions(-) diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx index 9fc91f6815..1ddcd583fc 100644 --- a/src/components/Menu/context.tsx +++ b/src/components/Menu/context.tsx @@ -1,8 +1,12 @@ import React from 'react' -import type {ContextType} from '#/components/Menu/types' +import type {ContextType, ItemContextType} from '#/components/Menu/types' export const Context = React.createContext({ // @ts-ignore control: null, }) + +export const ItemContext = React.createContext({ + disabled: false, +}) diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index 3be69b3486..a0a21a50f9 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -9,7 +9,7 @@ import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useInteractionState} from '#/components/hooks/useInteractionState' -import {Context} from '#/components/Menu/context' +import {Context, ItemContext} from '#/components/Menu/context' import { ContextType, GroupProps, @@ -125,8 +125,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) { }} onFocus={onFocus} onBlur={onBlur} - onPressIn={onPressIn} - onPressOut={onPressOut} + onPressIn={e => { + onPressIn() + rest.onPressIn?.(e) + }} + onPressOut={e => { + onPressOut() + rest.onPressOut?.(e) + }} style={[ a.flex_row, a.align_center, @@ -138,15 +144,18 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) { t.atoms.border_contrast_low, {minHeight: 44, paddingVertical: 10}, style, - (focused || pressed) && [t.atoms.bg_contrast_50], + (focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50], ]}> - {children} + + {children} + ) } export function ItemText({children, style}: ItemTextProps) { const t = useTheme() + const {disabled} = React.useContext(ItemContext) return ( {children} @@ -166,7 +176,17 @@ export function ItemText({children, style}: ItemTextProps) { export function ItemIcon({icon: Comp}: ItemIconProps) { const t = useTheme() - return + const {disabled} = React.useContext(ItemContext) + return ( + + ) } export function Group({children, style}: GroupProps) { diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index 031250ddef..6d2f5e9416 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -9,7 +9,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import {atoms as a, flatten, useTheme, web} from '#/alf' import * as Dialog from '#/components/Dialog' import {useInteractionState} from '#/components/hooks/useInteractionState' -import {Context} from '#/components/Menu/context' +import {Context, ItemContext} from '#/components/Menu/context' import { ContextType, GroupProps, @@ -239,18 +239,21 @@ export function Item({children, label, onPress, ...rest}: ItemProps) { a.rounded_xs, {minHeight: 32, paddingHorizontal: 10}, web({outline: 0}), - (hovered || focused) && [ - web({outline: '0 !important'}), - t.name === 'light' - ? t.atoms.bg_contrast_25 - : t.atoms.bg_contrast_50, - ], + (hovered || focused) && + !rest.disabled && [ + web({outline: '0 !important'}), + t.name === 'light' + ? t.atoms.bg_contrast_25 + : t.atoms.bg_contrast_50, + ], ])} {...web({ onMouseEnter, onMouseLeave, })}> - {children} + + {children} + ) @@ -258,8 +261,16 @@ export function Item({children, label, onPress, ...rest}: ItemProps) { export function ItemText({children, style}: ItemTextProps) { const t = useTheme() + const {disabled} = React.useContext(ItemContext) return ( - + {children} ) @@ -267,10 +278,9 @@ export function ItemText({children, style}: ItemTextProps) { export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) { const t = useTheme() + const {disabled} = React.useContext(ItemContext) return ( - + ]}> + + ) } diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts index e710971ee9..2f7aea5de5 100644 --- a/src/components/Menu/types.ts +++ b/src/components/Menu/types.ts @@ -1,18 +1,22 @@ import React from 'react' import { + AccessibilityProps, GestureResponderEvent, PressableProps, - AccessibilityProps, } from 'react-native' -import {Props as SVGIconProps} from '#/components/icons/common' -import * as Dialog from '#/components/Dialog' import {TextStyleProp, ViewStyleProp} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {Props as SVGIconProps} from '#/components/icons/common' export type ContextType = { control: Dialog.DialogOuterProps['control'] } +export type ItemContextType = { + disabled: boolean +} + export type RadixPassThroughTriggerProps = { id: string type: 'button'