From f1031d100b583fa289e654c5dfba6d251ea85272 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 8 Aug 2024 15:25:42 +0100 Subject: [PATCH 01/50] Cleanup flags (#4891) * Remove launched gates * Remove disabled gates --- src/lib/notifications/notifications.ts | 14 ++------------ src/lib/statsig/gates.ts | 3 --- src/view/com/posts/Feed.tsx | 3 +-- src/view/com/util/UserAvatar.tsx | 12 ++---------- 4 files changed, 5 insertions(+), 27 deletions(-) diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index fafc66143a..88b9eee3a5 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -5,7 +5,7 @@ import {BskyAgent} from '@atproto/api' import {logger} from '#/logger' import {SessionAccount, useAgent, useSession} from '#/state/session' -import {logEvent, useGate} from 'lib/statsig/statsig' +import {logEvent} from 'lib/statsig/statsig' import {devicePlatform, isAndroid, isNative} from 'platform/detection' import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler' @@ -86,7 +86,6 @@ export function useNotificationsRegistration() { } export function useRequestNotificationsPermission() { - const gate = useGate() const {currentAccount} = useSession() const agent = useAgent() @@ -102,16 +101,7 @@ export function useRequestNotificationsPermission() { ) { return } - if ( - context === 'StartOnboarding' && - gate('request_notifications_permission_after_onboarding_v2') - ) { - return - } - if ( - context === 'AfterOnboarding' && - !gate('request_notifications_permission_after_onboarding_v2') - ) { + if (context === 'AfterOnboarding') { return } if (context === 'Home' && !currentAccount) { diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 4b482b47dc..aa65e42a05 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -6,13 +6,10 @@ export type Gate = | 'new_user_guided_tour' | 'new_user_progress_guide' | 'onboarding_minimum_interests' - | 'request_notifications_permission_after_onboarding_v2' | 'session_withproxy_fix' | 'show_avi_follow_button' | 'show_follow_back_label_v2' | 'suggested_feeds_interstitial' - | 'suggested_follows_interstitial' | 'ungroup_follow_backs' | 'video_debug' | 'videos' - | 'small_avi_thumb' diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index ef46333193..54ea9b1400 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -349,8 +349,7 @@ let Feed = ({ const shouldShow = (interstitial.type === feedInterstitialType && gate('suggested_feeds_interstitial')) || - (interstitial.type === followInterstitialType && - gate('suggested_follows_interstitial')) || + interstitial.type === followInterstitialType || interstitial.type === progressGuideInterstitialType if (shouldShow) { diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 8862bd0e4a..66c9708bfc 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -8,7 +8,6 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {usePalette} from 'lib/hooks/usePalette' import { @@ -179,7 +178,6 @@ let UserAvatar = ({ const pal = usePalette('default') const backgroundColor = pal.colors.backgroundLight const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') - const gate = useGate() const aviStyle = useMemo(() => { if (finalShape === 'square') { @@ -223,10 +221,7 @@ let UserAvatar = ({ style={aviStyle} resizeMode="cover" source={{ - uri: hackModifyThumbnailPath( - avatar, - size < 90 && gate('small_avi_thumb'), - ), + uri: hackModifyThumbnailPath(avatar, size < 90), }} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} /> @@ -236,10 +231,7 @@ let UserAvatar = ({ style={aviStyle} contentFit="cover" source={{ - uri: hackModifyThumbnailPath( - avatar, - size < 90 && gate('small_avi_thumb'), - ), + uri: hackModifyThumbnailPath(avatar, size < 90), }} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} /> From 2174feed441459448668934015810fe0eb876dde Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 8 Aug 2024 15:49:34 +0100 Subject: [PATCH 02/50] Include follow-based suggestions in interstitial (#4889) --- src/components/FeedInterstitials.tsx | 14 ++++++++++++-- src/state/queries/profile.ts | 15 +++++++++++++++ src/state/userActionHistory.ts | 13 +++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index eca1c86f00..e37d2c3e0e 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -133,16 +133,26 @@ function useExperimentalSuggestedUsersQuery() { const {currentAccount} = useSession() const userActionSnapshot = userActionHistory.useActionHistorySnapshot() const dids = React.useMemo(() => { - const {likes, follows, seen} = userActionSnapshot + const {likes, follows, followSuggestions, seen} = userActionSnapshot const likeDids = likes .map(l => new AtUri(l)) .map(uri => uri.host) .filter(did => !follows.includes(did)) + let suggestedDids: string[] = [] + if (followSuggestions.length > 0) { + suggestedDids = [ + // It's ok if these will pick the same item (weighed by its frequency) + followSuggestions[Math.floor(Math.random() * followSuggestions.length)], + followSuggestions[Math.floor(Math.random() * followSuggestions.length)], + followSuggestions[Math.floor(Math.random() * followSuggestions.length)], + followSuggestions[Math.floor(Math.random() * followSuggestions.length)], + ] + } const seenDids = seen .sort(sortSeenPosts) .map(l => new AtUri(l.uri)) .map(uri => uri.host) - return [...new Set([...likeDids, ...seenDids])].filter( + return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter( did => did !== currentAccount?.did, ) }, [userActionSnapshot, currentAccount]) diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 1f866d26d2..6682cf3c89 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -222,6 +222,7 @@ export function useProfileFollowMutationQueue( logContext: LogEvents['profile:follow']['logContext'] & LogEvents['profile:unfollow']['logContext'], ) { + const agent = useAgent() const queryClient = useQueryClient() const did = profile.did const initialFollowingUri = profile.viewer?.following @@ -253,6 +254,20 @@ export function useProfileFollowMutationQueue( updateProfileShadow(queryClient, did, { followingUri: finalFollowingUri, }) + + if (finalFollowingUri) { + agent.app.bsky.graph + .getSuggestedFollowsByActor({ + actor: did, + }) + .then(res => { + const dids = res.data.suggestions + .filter(a => !a.viewer?.following) + .map(a => a.did) + .slice(0, 8) + userActionHistory.followSuggestion(dids) + }) + } }, }) diff --git a/src/state/userActionHistory.ts b/src/state/userActionHistory.ts index d82b3723a4..8ffe7241e6 100644 --- a/src/state/userActionHistory.ts +++ b/src/state/userActionHistory.ts @@ -2,6 +2,7 @@ import React from 'react' const LIKE_WINDOW = 100 const FOLLOW_WINDOW = 100 +const FOLLOW_SUGGESTION_WINDOW = 100 const SEEN_WINDOW = 100 export type SeenPost = { @@ -22,6 +23,10 @@ export type UserActionHistory = { * The last 100 DIDs the user has followed */ follows: string[] + /* + * The last 100 DIDs of suggested follows based on last follows + */ + followSuggestions: string[] /** * The last 100 post URIs the user has seen from the Discover feed only */ @@ -31,6 +36,7 @@ export type UserActionHistory = { const userActionHistory: UserActionHistory = { likes: [], follows: [], + followSuggestions: [], seen: [], } @@ -58,6 +64,13 @@ export function follow(dids: string[]) { .concat(dids) .slice(-FOLLOW_WINDOW) } + +export function followSuggestion(dids: string[]) { + userActionHistory.followSuggestions = userActionHistory.followSuggestions + .concat(dids) + .slice(-FOLLOW_SUGGESTION_WINDOW) +} + export function unfollow(dids: string[]) { userActionHistory.follows = userActionHistory.follows.filter( uri => !dids.includes(uri), From 4b71950d9920913a2a2cc9e493b23b87aad7cec1 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 8 Aug 2024 17:05:51 +0100 Subject: [PATCH 03/50] Remove unnecessary state update for reply gate (#4897) * Move mobile compose prompt to inner component * Make canReply computed * Use same clamp we use elsewhere --- src/view/com/post-thread/PostThread.tsx | 48 ++++++++++++++++++------- src/view/screens/PostThread.tsx | 42 ++-------------------- 2 files changed, 38 insertions(+), 52 deletions(-) diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index b7eaedd363..4014574124 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,11 +1,14 @@ -import React, {useEffect, useRef} from 'react' -import {useWindowDimensions, View} from 'react-native' +import React, {useRef} from 'react' +import {StyleSheet, useWindowDimensions, View} from 'react-native' import {runOnJS} from 'react-native-reanimated' +import Animated from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {AppBskyFeedDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' +import {clamp} from '#/lib/numbers' import {ScrollProvider} from '#/lib/ScrollContext' import {isAndroid, isNative, isWeb} from '#/platform/detection' import {useModerationOpts} from '#/state/preferences/moderation-opts' @@ -22,6 +25,7 @@ import { import {usePreferencesQuery} from '#/state/queries/preferences' import {useSession} from '#/state/session' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform' import {useSetTitle} from 'lib/hooks/useSetTitle' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {sanitizeDisplayName} from 'lib/strings/display-names' @@ -82,11 +86,9 @@ const keyExtractor = (item: RowItem) => { export function PostThread({ uri, - onCanReply, onPressReply, }: { uri: string | undefined - onCanReply: (canReply: boolean) => void onPressReply: () => unknown }) { const {hasSession, currentAccount} = useSession() @@ -210,14 +212,6 @@ export function PostThread({ return null }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) - useEffect(() => { - if (error) { - onCanReply(false) - } else if (rootPost) { - onCanReply(!rootPost.viewer?.replyDisabled) - } - }, [rootPost, onCanReply, error]) - // construct content const posts = React.useMemo(() => { if (!skeleton) return [] @@ -313,6 +307,7 @@ export function PostThread({ setMaxReplies(prev => prev + 50) }, [isFetching, maxReplies, posts.length]) + const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled const hasParents = skeleton?.highlightedPost?.type === 'post' && (skeleton.highlightedPost.ctx.isParentLoading || @@ -473,10 +468,30 @@ export function PostThread({ sideBorders={false} /> + {isMobile && canReply && hasSession && ( + + )} ) } +function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { + const safeAreaInsets = useSafeAreaInsets() + const fabMinimalShellTransform = useMinimalShellFabTransform() + return ( + + + + ) +} + function isThreadPost(v: unknown): v is ThreadPost { return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' } @@ -622,3 +637,12 @@ function hasBranchingReplies(node?: ThreadNode) { } return true } + +const styles = StyleSheet.create({ + prompt: { + // @ts-ignore web-only + position: isWeb ? 'fixed' : 'absolute', + left: 0, + right: 0, + }, +}) diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 89234c46b8..43dae2f1a1 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,39 +1,26 @@ import React from 'react' -import {StyleSheet, View} from 'react-native' -import Animated from 'react-native-reanimated' -import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' -import {clamp} from 'lodash' -import {isWeb} from '#/platform/detection' import { RQKEY as POST_THREAD_RQKEY, ThreadNode, } from '#/state/queries/post-thread' -import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' import {useComposerControls} from '#/state/shell/composer' -import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {makeRecordUri} from 'lib/strings/url-helpers' import {s} from 'lib/styles' -import {ComposePrompt} from 'view/com/composer/Prompt' import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' type Props = NativeStackScreenProps export function PostThreadScreen({route}: Props) { const queryClient = useQueryClient() - const {hasSession} = useSession() - const fabMinimalShellTransform = useMinimalShellFabTransform() const setMinimalShellMode = useSetMinimalShellMode() const {openComposer} = useComposerControls() - const safeAreaInsets = useSafeAreaInsets() const {name, rkey} = route.params - const {isMobile} = useWebMediaQueries() const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - const [canReply, setCanReply] = React.useState(false) useFocusEffect( React.useCallback(() => { @@ -67,33 +54,8 @@ export function PostThreadScreen({route}: Props) { return ( - + - {isMobile && canReply && hasSession && ( - - - - )} ) } - -const styles = StyleSheet.create({ - prompt: { - // @ts-ignore web-only - position: isWeb ? 'fixed' : 'absolute', - left: 0, - right: 0, - }, -}) From f3a9f874d96d8cf4e1705c8c9086949bd59b25c0 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 8 Aug 2024 11:48:24 -0500 Subject: [PATCH 04/50] Remove ungroup_follow_backs gate (#4893) --- src/lib/statsig/gates.ts | 1 - src/state/queries/notifications/feed.ts | 3 --- src/state/queries/notifications/unread.tsx | 5 +---- src/state/queries/notifications/util.ts | 9 ++------- src/view/com/notifications/FeedItem.tsx | 4 +--- 5 files changed, 4 insertions(+), 18 deletions(-) diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index aa65e42a05..7cdf7551ea 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -10,6 +10,5 @@ export type Gate = | 'show_avi_follow_button' | 'show_follow_back_label_v2' | 'suggested_feeds_interstitial' - | 'ungroup_follow_backs' | 'video_debug' | 'videos' diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 3054860db2..997076e819 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -26,7 +26,6 @@ import { useQueryClient, } from '@tanstack/react-query' -import {useGate} from '#/lib/statsig/statsig' import {useAgent} from '#/state/session' import {useModerationOpts} from '../../preferences/moderation-opts' import {STALE} from '..' @@ -59,7 +58,6 @@ export function useNotificationFeedQuery(opts?: { const moderationOpts = useModerationOpts() const unreads = useUnreadNotificationsApi() const enabled = opts?.enabled !== false - const gate = useGate() // false: force showing all notifications // undefined: let the server decide @@ -88,7 +86,6 @@ export function useNotificationFeedQuery(opts?: { queryClient, moderationOpts, fetchAdditionalData: true, - shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'), priority, }) page = fetchedPage diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index b5f7d0d60b..7bb325ea98 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -8,7 +8,6 @@ import {useQueryClient} from '@tanstack/react-query' import EventEmitter from 'eventemitter3' import BroadcastChannel from '#/lib/broadcast' -import {useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {useAgent, useSession} from '#/state/session' import {resetBadgeCount} from 'lib/notifications/notifications' @@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const agent = useAgent() const queryClient = useQueryClient() const moderationOpts = useModerationOpts() - const gate = useGate() const [numUnread, setNumUnread] = React.useState('') @@ -151,7 +149,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // only fetch subjects when the page is going to be used // in the notifications query, otherwise skip it fetchAdditionalData: !!invalidate, - shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'), }) const unreadCount = countUnread(page) const unreadCountStr = @@ -192,7 +189,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }, } - }, [setNumUnread, queryClient, moderationOpts, agent, gate]) + }, [setNumUnread, queryClient, moderationOpts, agent]) checkUnreadRef.current = api.checkUnread return ( diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index 7651e414a4..e0ee02294e 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -30,7 +30,6 @@ export async function fetchPage({ queryClient, moderationOpts, fetchAdditionalData, - shouldUngroupFollowBacks, }: { agent: BskyAgent cursor: string | undefined @@ -38,7 +37,6 @@ export async function fetchPage({ queryClient: QueryClient moderationOpts: ModerationOpts | undefined fetchAdditionalData: boolean - shouldUngroupFollowBacks?: () => boolean priority?: boolean }): Promise<{ page: FeedPage @@ -58,7 +56,7 @@ export async function fetchPage({ ) // group notifications which are essentially similar (follows, likes on a post) - let notifsGrouped = groupNotifications(notifs, {shouldUngroupFollowBacks}) + let notifsGrouped = groupNotifications(notifs) // we fetch subjects of notifications (usually posts) now instead of lazily // in the UI to avoid relayouts @@ -117,7 +115,6 @@ export function shouldFilterNotif( export function groupNotifications( notifs: AppBskyNotificationListNotifications.Notification[], - options?: {shouldUngroupFollowBacks?: () => boolean}, ): FeedNotification[] { const groupedNotifs: FeedNotification[] = [] for (const notif of notifs) { @@ -137,9 +134,7 @@ export function groupNotifications( const prevIsFollowBack = groupedNotif.notification.reason === 'follow' && groupedNotif.notification.author.viewer?.following - const shouldUngroup = - (nextIsFollowBack || prevIsFollowBack) && - options?.shouldUngroupFollowBacks?.() + const shouldUngroup = nextIsFollowBack || prevIsFollowBack if (!shouldUngroup) { groupedNotif.additional = groupedNotif.additional || [] groupedNotif.additional.push(notif) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index e4294eaa5f..1afc0f9d37 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -25,7 +25,6 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' -import {useGate} from '#/lib/statsig/statsig' import {parseTenorGif} from '#/lib/strings/embed-player' import {logger} from '#/logger' import {FeedNotification} from '#/state/queries/notifications/feed' @@ -87,7 +86,6 @@ let FeedItem = ({ const pal = usePalette('default') const {_} = useLingui() const t = useTheme() - const gate = useGate() const [isAuthorsExpanded, setAuthorsExpanded] = useState(false) const itemHref = useMemo(() => { if (item.type === 'post-like' || item.type === 'repost') { @@ -207,7 +205,7 @@ let FeedItem = ({ } } - if (isFollowBack && gate('ungroup_follow_backs')) { + if (isFollowBack) { action = _(msg`followed you back`) } else { action = _(msg`followed you`) From e1df2b0b160c32ef2d62eef067808b1ff8c25e51 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 8 Aug 2024 11:48:32 -0500 Subject: [PATCH 05/50] Remove explore_page_profile_card_social_proof (#4894) --- src/lib/statsig/gates.ts | 1 - src/view/screens/Search/Explore.tsx | 8 ++------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 7cdf7551ea..f0dd08185c 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,7 +1,6 @@ export type Gate = // Keep this alphabetic please. | 'debug_show_feedcontext' - | 'explore_page_profile_card_social_proof' | 'native_pwi_disabled' | 'new_user_guided_tour' | 'new_user_progress_guide' diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx index 5510fbee25..a36c404444 100644 --- a/src/view/screens/Search/Explore.tsx +++ b/src/view/screens/Search/Explore.tsx @@ -10,7 +10,6 @@ import { import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import {useModerationOpts} from '#/state/preferences/moderation-opts' @@ -293,7 +292,6 @@ export function Explore() { error: feedsError, fetchNextPage: fetchNextFeedsPage, } = useGetPopularFeedsQuery({limit: 10}) - const gate = useGate() const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles const onLoadMoreProfiles = React.useCallback(async () => { @@ -499,9 +497,7 @@ export function Explore() { profile={item.profile} noBg noBorder - showKnownFollowers={gate( - 'explore_page_profile_card_social_proof', - )} + showKnownFollowers /> ) @@ -565,7 +561,7 @@ export function Explore() { } } }, - [t, moderationOpts, gate], + [t, moderationOpts], ) return ( From 85fe95c988d34384879a74164ba58d329e34def8 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 8 Aug 2024 11:48:42 -0500 Subject: [PATCH 06/50] Remove new_user_progress_guide (#4895) --- src/lib/statsig/gates.ts | 1 - src/state/shell/progress-guide.tsx | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index f0dd08185c..7631ff3f44 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -3,7 +3,6 @@ export type Gate = | 'debug_show_feedcontext' | 'native_pwi_disabled' | 'new_user_guided_tour' - | 'new_user_progress_guide' | 'onboarding_minimum_interests' | 'session_withproxy_fix' | 'show_avi_follow_button' diff --git a/src/state/shell/progress-guide.tsx b/src/state/shell/progress-guide.tsx index c9b42a263e..d64e9984f5 100644 --- a/src/state/shell/progress-guide.tsx +++ b/src/state/shell/progress-guide.tsx @@ -2,7 +2,6 @@ import React from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useGate} from '#/lib/statsig/statsig' import { ProgressGuideToast, ProgressGuideToastRef, @@ -61,7 +60,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const {data: preferences} = usePreferencesQuery() const {mutateAsync, variables, isPending} = useSetActiveProgressGuideMutation() - const gate = useGate() const activeProgressGuide = ( isPending ? variables : preferences?.bskyAppState?.activeProgressGuide @@ -89,9 +87,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const controls = React.useMemo(() => { return { startProgressGuide(guide: ProgressGuideName) { - if (!gate('new_user_progress_guide')) { - return - } if (guide === 'like-10-and-follow-7') { const guideObj = { guide: 'like-10-and-follow-7', @@ -148,7 +143,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { mutateAsync(guide?.isComplete ? undefined : guide) }, } - }, [activeProgressGuide, mutateAsync, gate, setLocalGuideState]) + }, [activeProgressGuide, mutateAsync, setLocalGuideState]) return ( From ae25cb33919c2d308d24d2ac20d884871ad71ce3 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 8 Aug 2024 17:58:58 +0100 Subject: [PATCH 07/50] Move onPressReply into child component (#4898) * Move ComposePrompt to post-thread/ * Move onPressReply into child component --- src/view/com/post-thread/PostThread.tsx | 34 +++++++++++++------ .../PostThreadComposePrompt.tsx} | 6 +++- src/view/screens/PostThread.tsx | 34 ++----------------- 3 files changed, 31 insertions(+), 43 deletions(-) rename src/view/com/{composer/Prompt.tsx => post-thread/PostThreadComposePrompt.tsx} (94%) diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 4014574124..704c7d325a 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -24,6 +24,7 @@ import { } from '#/state/queries/post-thread' import {usePreferencesQuery} from '#/state/queries/preferences' import {useSession} from '#/state/session' +import {useComposerControls} from '#/state/shell' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform' import {useSetTitle} from 'lib/hooks/useSetTitle' @@ -34,9 +35,9 @@ import {CenteredView} from 'view/com/util/Views' import {atoms as a, useTheme} from '#/alf' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {Text} from '#/components/Typography' -import {ComposePrompt} from '../composer/Prompt' import {List, ListMethods} from '../util/List' import {ViewHeader} from '../util/ViewHeader' +import {PostThreadComposePrompt} from './PostThreadComposePrompt' import {PostThreadItem} from './PostThreadItem' import {PostThreadLoadMore} from './PostThreadLoadMore' import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies' @@ -84,13 +85,7 @@ const keyExtractor = (item: RowItem) => { return item._reactKey } -export function PostThread({ - uri, - onPressReply, -}: { - uri: string | undefined - onPressReply: () => unknown -}) { +export function PostThread({uri}: {uri: string | undefined}) { const {hasSession, currentAccount} = useSession() const {_} = useLingui() const t = useTheme() @@ -307,6 +302,23 @@ export function PostThread({ setMaxReplies(prev => prev + 50) }, [isFetching, maxReplies, posts.length]) + const {openComposer} = useComposerControls() + const onPressReply = React.useCallback(() => { + if (thread?.type !== 'post') { + return + } + openComposer({ + replyTo: { + uri: thread.post.uri, + cid: thread.post.cid, + text: thread.record.text, + author: thread.post.author, + embed: thread.post.embed, + }, + onPost: () => refetch(), + }) + }, [openComposer, thread, refetch]) + const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled const hasParents = skeleton?.highlightedPost?.type === 'post' && @@ -319,7 +331,9 @@ export function PostThread({ if (item === REPLY_PROMPT && hasSession) { return ( - {!isMobile && } + {!isMobile && ( + + )} ) } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { @@ -487,7 +501,7 @@ function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { bottom: clamp(safeAreaInsets.bottom, 15, 30), }, ]}> - + ) } diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx similarity index 94% rename from src/view/com/composer/Prompt.tsx rename to src/view/com/post-thread/PostThreadComposePrompt.tsx index 20637c7e9f..62b28cc759 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx @@ -10,7 +10,11 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { +export function PostThreadComposePrompt({ + onPressCompose, +}: { + onPressCompose: () => void +}) { const {currentAccount} = useSession() const {data: profile} = useProfileQuery({did: currentAccount?.did}) const pal = usePalette('default') diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 43dae2f1a1..88d0726c12 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,14 +1,8 @@ import React from 'react' import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' -import { - RQKEY as POST_THREAD_RQKEY, - ThreadNode, -} from '#/state/queries/post-thread' import {useSetMinimalShellMode} from '#/state/shell' -import {useComposerControls} from '#/state/shell/composer' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {makeRecordUri} from 'lib/strings/url-helpers' import {s} from 'lib/styles' @@ -16,9 +10,8 @@ import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' type Props = NativeStackScreenProps export function PostThreadScreen({route}: Props) { - const queryClient = useQueryClient() const setMinimalShellMode = useSetMinimalShellMode() - const {openComposer} = useComposerControls() + const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) @@ -28,33 +21,10 @@ export function PostThreadScreen({route}: Props) { }, [setMinimalShellMode]), ) - const onPressReply = React.useCallback(() => { - if (!uri) { - return - } - const thread = queryClient.getQueryData(POST_THREAD_RQKEY(uri)) - if (thread?.type !== 'post') { - return - } - openComposer({ - replyTo: { - uri: thread.post.uri, - cid: thread.post.cid, - text: thread.record.text, - author: thread.post.author, - embed: thread.post.embed, - }, - onPost: () => - queryClient.invalidateQueries({ - queryKey: POST_THREAD_RQKEY(uri), - }), - }) - }, [openComposer, queryClient, uri]) - return ( - + ) From e7a0055a8514a134d3fc897b5058d14070518b2e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 8 Aug 2024 18:04:40 +0100 Subject: [PATCH 08/50] Fix overflow on posts (#4899) * overflow posts, via contentHider * margin->padding for a bit more leeway * overflow notifications * overflow on header * revert from contenthider, put on text in all places * fix zalgo text in handle in composer --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- src/components/moderation/ContentHider.tsx | 22 ++----------------- .../Profile/Header/ProfileHeaderStandard.tsx | 2 +- src/view/com/composer/ComposerReplyTo.tsx | 2 +- src/view/com/notifications/FeedItem.tsx | 6 ++--- src/view/com/post-thread/PostThreadItem.tsx | 1 + src/view/com/post/Post.tsx | 13 ++++++----- src/view/com/posts/FeedItem.tsx | 1 + 7 files changed, 15 insertions(+), 32 deletions(-) diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx index 45122a4efe..f2d13f6424 100644 --- a/src/components/moderation/ContentHider.tsx +++ b/src/components/moderation/ContentHider.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {StyleProp, View, ViewStyle} from 'react-native' import {ModerationUI} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -40,7 +40,7 @@ export function ContentHider({ if (!blur || (ignoreMute && isJustAMute(modui))) { return ( - + {children} ) @@ -163,21 +163,3 @@ export function ContentHider({ ) } - -const styles = StyleSheet.create({ - outer: {}, - cover: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - borderRadius: 8, - marginTop: 4, - paddingVertical: 14, - paddingLeft: 14, - paddingRight: 18, - }, - showBtn: { - marginLeft: 'auto', - alignSelf: 'center', - }, -}) diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index b275b31910..2b6353b276 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -157,7 +157,7 @@ let ProfileHeaderStandard = ({ hideBackButton={hideBackButton} isPlaceholderProfile={isPlaceholderProfile}> - + {sanitizeDisplayName( replyTo.author.displayName || sanitizeHandle(replyTo.author.handle), )} diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 1afc0f9d37..b591b4b7fd 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -253,6 +253,7 @@ let FeedItem = ({ borderColor: pal.colors.unreadNotifBorder, }, {borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth}, + a.overflow_hidden, ]} href={itemHref} noFeedback @@ -545,7 +546,7 @@ function ExpandedAuthorsList({ }, [heightInterp, visible]) return ( - + {visible && authors.map(author => ( Date: Thu, 8 Aug 2024 12:14:02 -0500 Subject: [PATCH 09/50] Remove native_pwi_disabled (#4896) * Remove native_pwi_disabled * Remove search button --- src/lib/statsig/gates.ts | 1 - src/view/com/auth/LoggedOut.tsx | 50 +------------------ .../createNativeStackNavigatorWithAuth.tsx | 9 +--- 3 files changed, 3 insertions(+), 57 deletions(-) diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 7631ff3f44..c2b601c3c5 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,7 +1,6 @@ export type Gate = // Keep this alphabetic please. | 'debug_show_feedcontext' - | 'native_pwi_disabled' | 'new_user_guided_tour' | 'onboarding_minimum_interests' | 'session_withproxy_fix' diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index cc71f648e9..cc57238053 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -1,25 +1,20 @@ import React from 'react' import {Pressable, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' import {useAnalytics} from '#/lib/analytics/analytics' import {usePalette} from '#/lib/hooks/usePalette' import {logEvent} from '#/lib/statsig/statsig' import {s} from '#/lib/styles' -import {isIOS, isNative} from '#/platform/detection' -import {useSession} from '#/state/session' +import {isIOS} from '#/platform/detection' import { useLoggedOutView, useLoggedOutViewControls, } from '#/state/shell/logged-out' import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' -import {NavigationProp} from 'lib/routes/types' -import {useGate} from 'lib/statsig/statsig' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' -import {Text} from '#/view/com/util/text/Text' import {Login} from '#/screens/Login' import {Signup} from '#/screens/Signup' import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen' @@ -34,7 +29,6 @@ enum ScreenState { export {ScreenState as LoggedOutScreenState} export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { - const {hasSession} = useSession() const {_} = useLingui() const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() @@ -52,10 +46,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { } }) const {clearRequestedAccount} = useLoggedOutViewControls() - const navigation = useNavigation() - const gate = useGate() - const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount React.useEffect(() => { screen('Login') setMinimalShellMode(true) @@ -68,10 +59,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { clearRequestedAccount() }, [clearRequestedAccount, onDismiss]) - const onPressSearch = React.useCallback(() => { - navigation.navigate(`SearchTab`) - }, [navigation]) - return ( @@ -98,39 +85,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { }} /> - ) : isNative && - !hasSession && - isFirstScreen && - !gate('native_pwi_disabled') ? ( - - - Search{' '} - - - ) : null} {screenState === ScreenState.S_StarterPack ? ( diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx index 203e7e5077..7951eef75b 100644 --- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx +++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx @@ -29,7 +29,6 @@ import { useLoggedOutView, useLoggedOutViewControls, } from '#/state/shell/logged-out' -import {useGate} from 'lib/statsig/statsig' import {isNative, isWeb} from 'platform/detection' import {Deactivated} from '#/screens/Deactivated' import {Onboarding} from '#/screens/Onboarding' @@ -51,7 +50,6 @@ function NativeStackNavigator({ screenOptions, ...rest }: NativeStackNavigatorProps) { - const gate = useGate() // --- this is copy and pasted from the original native stack navigator --- const {state, descriptors, navigation, NavigationContent} = useNavigationBuilder< @@ -102,12 +100,7 @@ function NativeStackNavigator({ const {showLoggedOut} = useLoggedOutView() const {setShowLoggedOut} = useLoggedOutViewControls() const {isMobile, isTabletOrMobile} = useWebMediaQueries() - if ( - !hasSession && - (!PWI_ENABLED || - activeRouteRequiresAuth || - (isNative && gate('native_pwi_disabled'))) - ) { + if (!hasSession && (!PWI_ENABLED || activeRouteRequiresAuth || isNative)) { return } if (hasSession && currentAccount?.signupQueued) { From a0756c53be7991de1b2bd0c536c25e0d5995c992 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 8 Aug 2024 12:14:15 -0500 Subject: [PATCH 10/50] Remove show_avi_follow_button (#4900) --- src/lib/statsig/gates.ts | 1 - src/view/com/posts/AviFollowButton.tsx | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index c2b601c3c5..5ae6bd5300 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -4,7 +4,6 @@ export type Gate = | 'new_user_guided_tour' | 'onboarding_minimum_interests' | 'session_withproxy_fix' - | 'show_avi_follow_button' | 'show_follow_back_label_v2' | 'suggested_feeds_interstitial' | 'video_debug' diff --git a/src/view/com/posts/AviFollowButton.tsx b/src/view/com/posts/AviFollowButton.tsx index f7141ee421..00428cbe61 100644 --- a/src/view/com/posts/AviFollowButton.tsx +++ b/src/view/com/posts/AviFollowButton.tsx @@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native' import {createHitslop} from '#/lib/constants' import {NavigationProp} from '#/lib/routes/types' -import {useGate} from '#/lib/statsig/statsig' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' @@ -37,7 +36,6 @@ export function AviFollowButton({ profile: profile, logContext: 'AvatarButton', }) - const gate = useGate() const {currentAccount, hasSession} = useSession() const navigation = useNavigation() @@ -80,7 +78,7 @@ export function AviFollowButton({ }, ] - return hasSession && gate('show_avi_follow_button') ? ( + return hasSession ? ( {children} From c1af767fa62361407d0584a94bd206dbd92323bc Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 8 Aug 2024 10:51:54 -0700 Subject: [PATCH 11/50] Remove client filtering of starter packs (#4753) --- src/state/queries/profile-lists.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index 75e3dd6e48..03c983ff80 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -40,18 +40,10 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { pages: data.pages.map(page => { return { ...page, - lists: page.lists - /* - * Starter packs use a reference list, which we do not want to - * show on profiles. At some point we could probably just filter - * this out on the backend instead of in the client. - */ - .filter(l => l.purpose !== 'app.bsky.graph.defs#referencelist') - // filter by labels - .filter(list => { - const decision = moderateUserList(list, moderationOpts!) - return !decision.ui('contentList').filter - }), + lists: page.lists.filter(list => { + const decision = moderateUserList(list, moderationOpts!) + return !decision.ui('contentList').filter + }), } }), } From e782db33dcd415bf6ad122daedd3abd3aafabd64 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 8 Aug 2024 19:19:58 +0100 Subject: [PATCH 12/50] Show just-posted replies above OP replies (#4901) * Unify onPostReply handler * Show just-posted replies above OP replies * Only do this for the highlighted post or thread mode It's confusing to have your post displace OP thread or other people's leaf posts. --- src/state/queries/post-thread.ts | 19 +++++++++++- src/state/shell/composer.tsx | 5 ++-- src/view/com/composer/Composer.tsx | 2 +- src/view/com/post-thread/PostThread.tsx | 33 ++++++++++++++++++--- src/view/com/post-thread/PostThreadItem.tsx | 4 +-- 5 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index c01b96ed81..fd419d1c44 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -137,6 +137,7 @@ export function sortThread( opts: UsePreferencesQueryResponse['threadViewPrefs'], modCache: ThreadModerationCache, currentDid: string | undefined, + justPostedUris: Set, ): ThreadNode { if (node.type !== 'post') { return node @@ -150,6 +151,20 @@ export function sortThread( return -1 } + if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) { + const aIsJustPosted = + a.post.author.did === currentDid && justPostedUris.has(a.post.uri) + const bIsJustPosted = + b.post.author.did === currentDid && justPostedUris.has(b.post.uri) + if (aIsJustPosted && bIsJustPosted) { + return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest + } else if (aIsJustPosted) { + return -1 // reply while onscreen + } else if (bIsJustPosted) { + return 1 // reply while onscreen + } + } + const aIsByOp = a.post.author.did === node.post?.author.did const bIsByOp = b.post.author.did === node.post?.author.did if (aIsByOp && bIsByOp) { @@ -206,7 +221,9 @@ export function sortThread( } return b.post.indexedAt.localeCompare(a.post.indexedAt) }) - node.replies.forEach(reply => sortThread(reply, opts, modCache, currentDid)) + node.replies.forEach(reply => + sortThread(reply, opts, modCache, currentDid, justPostedUris), + ) } return node } diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx index 5b4e505439..e28d6b4ac0 100644 --- a/src/state/shell/composer.tsx +++ b/src/state/shell/composer.tsx @@ -1,10 +1,11 @@ import React from 'react' import { + AppBskyActorDefs, AppBskyEmbedRecord, AppBskyRichtextFacet, ModerationDecision, - AppBskyActorDefs, } from '@atproto/api' + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' export interface ComposerOptsPostRef { @@ -31,7 +32,7 @@ export interface ComposerOptsQuote { } export interface ComposerOpts { replyTo?: ComposerOptsPostRef - onPost?: () => void + onPost?: (postUri: string | undefined) => void quote?: ComposerOptsQuote mention?: string // handle of user to mention openPicker?: (pos: DOMRect | undefined) => void diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 08ce4441f0..dba37d82bf 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -392,7 +392,7 @@ export const ComposePost = observer(function ComposePost({ emitPostCreated() } setLangPrefs.savePostLanguageToHistory() - onPost?.() + onPost?.(postUri) onClose() Toast.show( replyTo diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 704c7d325a..c64be8d671 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -160,12 +160,22 @@ export function PostThread({uri}: {uri: string | undefined}) { return cache }, [thread, moderationOpts]) + const [justPostedUris, setJustPostedUris] = React.useState( + () => new Set(), + ) + const skeleton = React.useMemo(() => { const threadViewPrefs = preferences?.threadViewPrefs if (!threadViewPrefs || !thread) return null return createThreadSkeleton( - sortThread(thread, threadViewPrefs, threadModerationCache, currentDid), + sortThread( + thread, + threadViewPrefs, + threadModerationCache, + currentDid, + justPostedUris, + ), !!currentDid, treeView, threadModerationCache, @@ -178,6 +188,7 @@ export function PostThread({uri}: {uri: string | undefined}) { treeView, threadModerationCache, hiddenRepliesState, + justPostedUris, ]) const error = React.useMemo(() => { @@ -302,6 +313,20 @@ export function PostThread({uri}: {uri: string | undefined}) { setMaxReplies(prev => prev + 50) }, [isFetching, maxReplies, posts.length]) + const onPostReply = React.useCallback( + (postUri: string | undefined) => { + refetch() + if (postUri) { + setJustPostedUris(set => { + const nextSet = new Set(set) + nextSet.add(postUri) + return nextSet + }) + } + }, + [refetch], + ) + const {openComposer} = useComposerControls() const onPressReply = React.useCallback(() => { if (thread?.type !== 'post') { @@ -315,9 +340,9 @@ export function PostThread({uri}: {uri: string | undefined}) { author: thread.post.author, embed: thread.post.embed, }, - onPost: () => refetch(), + onPost: onPostReply, }) - }, [openComposer, thread, refetch]) + }, [openComposer, thread, onPostReply]) const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled const hasParents = @@ -415,7 +440,7 @@ export function PostThread({uri}: {uri: string | undefined}) { HiddenRepliesState.ShowAndOverridePostHider && item.ctx.depth > 0 } - onPostReply={refetch} + onPostReply={onPostReply} hideTopBorder={index === 0 && !item.ctx.isParentLoading} /> diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 29f17164fc..8adbb17e29 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -75,7 +75,7 @@ export function PostThreadItem({ showParentReplyLine?: boolean hasPrecedingItem: boolean overrideBlur: boolean - onPostReply: () => void + onPostReply: (postUri: string | undefined) => void hideTopBorder?: boolean }) { const postShadowed = usePostShadow(post) @@ -169,7 +169,7 @@ let PostThreadItemLoaded = ({ showParentReplyLine?: boolean hasPrecedingItem: boolean overrideBlur: boolean - onPostReply: () => void + onPostReply: (postUri: string | undefined) => void hideTopBorder?: boolean }): React.ReactNode => { const pal = usePalette('default') From 4350dbc853766dc4ba7cc304b7a12505ef0c4781 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 9 Aug 2024 02:33:42 +0100 Subject: [PATCH 13/50] [Video] Bundle of minor tweaks (#4904) * fix bg color * unique video urls for debug * improve controls slightly * mute until fullscreen --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- src/view/com/posts/FeedItem.tsx | 20 +++++-- .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 55 ++++++++++--------- .../util/post-embeds/VideoPlayerContext.tsx | 1 + 3 files changed, 45 insertions(+), 31 deletions(-) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 8592f0bec2..0071e2401b 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -1,4 +1,4 @@ -import React, {memo, useMemo, useState} from 'react' +import React, {memo, useId, useMemo, useState} from 'react' import {StyleSheet, View} from 'react-native' import { AppBskyActorDefs, @@ -137,7 +137,6 @@ let FeedItemInner = ({ const {openComposer} = useComposerControls() const pal = usePalette('default') const {_} = useLingui() - const gate = useGate() const href = useMemo(() => { const urip = new AtUri(post.uri) @@ -356,9 +355,7 @@ let FeedItemInner = ({ postAuthor={post.author} onOpenEmbed={onOpenEmbed} /> - {gate('video_debug') && ( - - )} + + ) +} + const styles = StyleSheet.create({ outer: { paddingLeft: 10, diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index cc356fb069..b95b9c809b 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -1,5 +1,6 @@ import React, {useEffect, useRef, useState} from 'react' import {Pressable, View} from 'react-native' +import Animated, {FadeIn} from 'react-native-reanimated' import {VideoPlayer, VideoView} from 'expo-video' import {useVideoPlayer} from 'view/com/util/post-embeds/VideoPlayerContext' @@ -20,7 +21,10 @@ export function VideoEmbedInnerNative() { /> ref.current?.enterFullscreen()} + enterFullscreen={() => { + player.muted = false + ref.current?.enterFullscreen() + }} /> ) @@ -56,34 +60,33 @@ function Controls({ } }, [player]) - if (isNaN(timeRemaining)) { - return null - } - return ( - - - {minutes}:{seconds} - - + { + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + position: 'absolute', + left: 5, + bottom: 5, + }, + ]} + pointerEvents="none"> + + {minutes}:{seconds} + + + )} { player.loop = true + player.muted = true player.play() }) From a4f0c9c753ec8844eda094080d81fbba566de7df Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 9 Aug 2024 03:24:38 +0100 Subject: [PATCH 14/50] [Video] more minor tweaks (#4906) * update expo-video * pause when on a different screen * rm collapsable * add mute/unmute button --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- package.json | 2 +- .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 76 ++++++++++++++++--- yarn.lock | 8 +- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index faeee448c9..7c6e13afb6 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,7 @@ "expo-system-ui": "~3.0.4", "expo-task-manager": "~11.8.1", "expo-updates": "~0.25.14", - "expo-video": "^1.1.10", + "expo-video": "^1.2.4", "expo-web-browser": "~13.0.3", "fast-text-encoding": "^1.0.6", "history": "^5.3.0", diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index b95b9c809b..26f6111bc7 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -1,18 +1,37 @@ -import React, {useEffect, useRef, useState} from 'react' +import React, {useCallback, useEffect, useRef, useState} from 'react' import {Pressable, View} from 'react-native' import Animated, {FadeIn} from 'react-native-reanimated' import {VideoPlayer, VideoView} from 'expo-video' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useIsFocused} from '@react-navigation/native' -import {useVideoPlayer} from 'view/com/util/post-embeds/VideoPlayerContext' -import {android, atoms as a} from '#/alf' +import {HITSLOP_30} from '#/lib/constants' +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' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' import {Text} from '#/components/Typography' export function VideoEmbedInnerNative() { const player = useVideoPlayer() const ref = useRef(null) + const isScreenFocused = useIsFocused() + + // pause the video when the screen is not focused + useEffect(() => { + if (!isScreenFocused) { + let wasPlaying = player.playing + player.pause() + + return () => { + if (wasPlaying) player.play() + } + } + }, [isScreenFocused, player]) return ( - + void }) { + const {_} = useLingui() + const t = useTheme() + const [isMuted, setIsMuted] = useState(player.muted) const [duration, setDuration] = useState(() => Math.floor(player.duration)) const [currentTime, setCurrentTime] = useState(() => Math.floor(player.currentTime), @@ -55,11 +77,21 @@ function Controls({ // 1000 gets out of sync with the video time }, 250) + // eslint-disable-next-line @typescript-eslint/no-shadow + const sub = player.addListener('volumeChange', ({isMuted}) => { + setIsMuted(isMuted) + }) + return () => { clearInterval(interval) + sub.remove() } }, [player]) + const toggleSound = useCallback(() => { + player.muted = !player.muted + }, [player]) + return ( {!isNaN(timeRemaining) && ( @@ -74,12 +106,13 @@ function Controls({ position: 'absolute', left: 5, bottom: 5, + minHeight: 20, + justifyContent: 'center', }, - ]} - pointerEvents="none"> + ]}> @@ -90,10 +123,35 @@ function Controls({ + + + {isMuted ? ( + + ) : ( + + )} + + ) } diff --git a/yarn.lock b/yarn.lock index 16547aa3ad..ba1227f30b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12302,10 +12302,10 @@ expo-updates@~0.25.14: ignore "^5.3.1" resolve-from "^5.0.0" -expo-video@^1.1.10: - version "1.1.10" - resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-1.1.10.tgz#b47c0d40c21f401236639424bd25d70c09316b7b" - integrity sha512-k9ecpgtwAK8Ut8enm8Jv398XkB/uVOyLLqk80M/d8pH9EN5CVrBQ7iEzWlR3quvVUFM7Uf5wRukJ4hk3mZ8NCg== +expo-video@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-1.2.4.tgz#787342aded4295a1b6864f59227d178b93e1bb53" + integrity sha512-pBK9mt7vYAbuPQjCSQxHQ7xrNjbmRheJep7JIStEg57O183/JRfP2blKuXniiSt1HBdZYPdoQnGRa3jGMXB9pg== expo-web-browser@~13.0.3: version "13.0.3" From cb574b7be3757c25063957ff561bae6f1be7577f Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 9 Aug 2024 03:05:12 -0700 Subject: [PATCH 15/50] [Videos] Add iOS function to set `AVAudioSession.CategoryOptions` to `.mixWithOthers` (#4905) * audio mixing pref * lint --- .../ios/PlatformInfo/ExpoPlatformInfoModule.swift | 10 ++++++++++ .../src/PlatformInfo/index.native.ts | 6 ++++++ .../expo-bluesky-swiss-army/src/PlatformInfo/index.ts | 4 ++++ .../src/PlatformInfo/index.web.ts | 6 ++++++ 4 files changed, 26 insertions(+) diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift index 4a1e6d7e7d..ff1148a0b5 100644 --- a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift +++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift @@ -7,5 +7,15 @@ public class ExpoPlatformInfoModule: Module { Function("getIsReducedMotionEnabled") { return UIAccessibility.isReduceMotionEnabled } + + Function("setAudioMixWithOthers") { (mixWithOthers: Bool) in + var options: AVAudioSession.CategoryOptions + if mixWithOthers { + options = [.mixWithOthers] + } else { + options = [] + } + try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: options) + } } } diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts index e05f173d64..949bd78a28 100644 --- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts @@ -1,3 +1,4 @@ +import {Platform} from 'react-native' import {requireNativeModule} from 'expo-modules-core' const NativeModule = requireNativeModule('ExpoPlatformInfo') @@ -5,3 +6,8 @@ const NativeModule = requireNativeModule('ExpoPlatformInfo') export function getIsReducedMotionEnabled(): boolean { return NativeModule.getIsReducedMotionEnabled() } + +export function setAudioMixWithOthers(mixWithOthers: boolean): void { + if (Platform.OS !== 'ios') return + NativeModule.setAudioMixWithOthers(mixWithOthers) +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts index 9b9b7fc0c7..2665748b0a 100644 --- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts @@ -3,3 +3,7 @@ import {NotImplementedError} from '../NotImplemented' export function getIsReducedMotionEnabled(): boolean { throw new NotImplementedError() } + +export function setAudioMixWithOthers(mixWithOthers: boolean): void { + throw new NotImplementedError({mixWithOthers}) +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts index c7ae6b7cd4..917c97396f 100644 --- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts @@ -1,6 +1,12 @@ +import {NotImplementedError} from '../NotImplemented' + export function getIsReducedMotionEnabled(): boolean { if (typeof window === 'undefined') { return false } return window.matchMedia('(prefers-reduced-motion: reduce)').matches } + +export function setAudioMixWithOthers(mixWithOthers: boolean): void { + throw new NotImplementedError({mixWithOthers}) +} From 0f993a09c2ac7147f9a555cb243c1aff8fc2e48c Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 9 Aug 2024 03:05:43 -0700 Subject: [PATCH 16/50] [Video] Add `onEnterFullscreen` and `onExitFullscreen` to `VideoView` (#4907) * patch expo video to add enter/exit fullscreen events * rm logs * Update expo-video+1.2.4.patch.md --- patches/expo-video+1.1.10.patch | 20 ----- patches/expo-video+1.2.4.patch | 135 ++++++++++++++++++++++++++++++ patches/expo-video+1.2.4.patch.md | 6 ++ 3 files changed, 141 insertions(+), 20 deletions(-) delete mode 100644 patches/expo-video+1.1.10.patch create mode 100644 patches/expo-video+1.2.4.patch create mode 100644 patches/expo-video+1.2.4.patch.md diff --git a/patches/expo-video+1.1.10.patch b/patches/expo-video+1.1.10.patch deleted file mode 100644 index b183be9d41..0000000000 --- a/patches/expo-video+1.1.10.patch +++ /dev/null @@ -1,20 +0,0 @@ ---- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt -+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt -@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole - setShowPreviousButton(!requireLinearPlayback) - setShowNextButton(!requireLinearPlayback) - setTimeBarInteractive(requireLinearPlayback) -+ setShowSubtitleButton(true) - } - - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) -@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) { - - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) - internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) { -- val fullscreenButton = findViewById(androidx.media3.ui.R.id.exo_fullscreen) -+ val fullscreenButton = -+ findViewById(androidx.media3.ui.R.id.exo_fullscreen) - fullscreenButton?.visibility = if (visible) { - android.view.View.VISIBLE - } else { diff --git a/patches/expo-video+1.2.4.patch b/patches/expo-video+1.2.4.patch new file mode 100644 index 0000000000..918c8a8d25 --- /dev/null +++ b/patches/expo-video+1.2.4.patch @@ -0,0 +1,135 @@ +diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt +index 9905e13..47342ff 100644 +--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt ++++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt +@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole + setShowPreviousButton(!requireLinearPlayback) + setShowNextButton(!requireLinearPlayback) + setTimeBarInteractive(requireLinearPlayback) ++ setShowSubtitleButton(true) + } + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) { + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) { +- val fullscreenButton = findViewById(androidx.media3.ui.R.id.exo_fullscreen) ++ val fullscreenButton = ++ findViewById(androidx.media3.ui.R.id.exo_fullscreen) + fullscreenButton?.visibility = if (visible) { + android.view.View.VISIBLE + } else { +diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt +index ec3da2a..5a1397a 100644 +--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt ++++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt +@@ -43,7 +43,9 @@ class VideoModule : Module() { + View(VideoView::class) { + Events( + "onPictureInPictureStart", +- "onPictureInPictureStop" ++ "onPictureInPictureStop", ++ "onEnterFullscreen", ++ "onExitFullscreen" + ) + + Prop("player") { view: VideoView, player: VideoPlayer -> +diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt +index a951d80..3932535 100644 +--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt ++++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt +@@ -36,6 +36,8 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap + val playerView: PlayerView = PlayerView(context.applicationContext) + val onPictureInPictureStart by EventDispatcher() + val onPictureInPictureStop by EventDispatcher() ++ val onEnterFullscreen by EventDispatcher() ++ val onExitFullscreen by EventDispatcher() + + var willEnterPiP: Boolean = false + var isInFullscreen: Boolean = false +@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap + @Suppress("DEPRECATION") + currentActivity.overridePendingTransition(0, 0) + } ++ onEnterFullscreen(mapOf()) + isInFullscreen = true + } + +@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap + val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen) + fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter) + videoPlayer?.changePlayerView(playerView) ++ this.onExitFullscreen(mapOf()) + isInFullscreen = false + } + +diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts +index cb9ca6d..60e9f4e 100644 +--- a/node_modules/expo-video/build/VideoView.types.d.ts ++++ b/node_modules/expo-video/build/VideoView.types.d.ts +@@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps { + * @platform ios 16.0+ + */ + allowsVideoFrameAnalysis?: boolean; ++ ++ onEnterFullscreen?: () => void; ++ onExitFullscreen?: () => void; + } + //# sourceMappingURL=VideoView.types.d.ts.map +diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift +index c537a12..e4a918f 100644 +--- a/node_modules/expo-video/ios/VideoModule.swift ++++ b/node_modules/expo-video/ios/VideoModule.swift +@@ -16,7 +16,9 @@ public final class VideoModule: Module { + View(VideoView.self) { + Events( + "onPictureInPictureStart", +- "onPictureInPictureStop" ++ "onPictureInPictureStop", ++ "onEnterFullscreen", ++ "onExitFullscreen" + ) + + Prop("player") { (view, player: VideoPlayer?) in +diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift +index f4579e4..10c5908 100644 +--- a/node_modules/expo-video/ios/VideoView.swift ++++ b/node_modules/expo-video/ios/VideoView.swift +@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate { + + let onPictureInPictureStart = EventDispatcher() + let onPictureInPictureStop = EventDispatcher() ++ let onEnterFullscreen = EventDispatcher() ++ let onExitFullscreen = EventDispatcher() + + public override var bounds: CGRect { + didSet { +@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate { + _ playerViewController: AVPlayerViewController, + willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator + ) { ++ onEnterFullscreen() + isFullscreen = true + } + +@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate { + if wasPlaying { + self.player?.pointer.play() + } ++ self.onExitFullscreen() + self.isFullscreen = false + } + } +diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts +index 29fe5db..e1fbf59 100644 +--- a/node_modules/expo-video/src/VideoView.types.ts ++++ b/node_modules/expo-video/src/VideoView.types.ts +@@ -100,4 +100,7 @@ export interface VideoViewProps extends ViewProps { + * @platform ios 16.0+ + */ + allowsVideoFrameAnalysis?: boolean; ++ ++ onEnterFullscreen?: () => void; ++ onExitFullscreen?: () => void; + } diff --git a/patches/expo-video+1.2.4.patch.md b/patches/expo-video+1.2.4.patch.md new file mode 100644 index 0000000000..689cf9a926 --- /dev/null +++ b/patches/expo-video+1.2.4.patch.md @@ -0,0 +1,6 @@ +## uwu woad beawing, do not wemove + +## `expo-video` Patch + +This patch adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on +the tin. From dd0d50a6f0f69d8b58f7dd26303b6b89528d2d04 Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 9 Aug 2024 03:25:54 -0700 Subject: [PATCH 17/50] [Video] Prevent pausing of background audio with `expo-video` on iOS (#4908) * audio mixing pref * lint * patch expo video to add enter/exit fullscreen events * rm logs * fix audio problems * toggle mute when enter/exiting fullscreen --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- src/App.native.tsx | 2 ++ .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/App.native.tsx b/src/App.native.tsx index d2c20fc8e7..29a6a738c5 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -61,6 +61,7 @@ import {Provider as PortalProvider} from '#/components/Portal' import {Splash} from '#/Splash' import {Provider as TourProvider} from '#/tours' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' +import {PlatformInfo} from '../modules/expo-bluesky-swiss-army' SplashScreen.preventAutoHideAsync() @@ -157,6 +158,7 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { + PlatformInfo.setAudioMixWithOthers(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 26f6111bc7..5768662606 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -12,6 +12,7 @@ import {android, atoms as a, useTheme} from '#/alf' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' import {Text} from '#/components/Typography' +import {PlatformInfo} from '../../../../../../modules/expo-bluesky-swiss-army' export function VideoEmbedInnerNative() { const player = useVideoPlayer() @@ -37,11 +38,18 @@ export function VideoEmbedInnerNative() { player={player} style={a.flex_1} nativeControls={true} + onEnterFullscreen={() => { + PlatformInfo.setAudioMixWithOthers(false) + player.muted = false + }} + onExitFullscreen={() => { + PlatformInfo.setAudioMixWithOthers(true) + player.muted = true + }} /> { - player.muted = false ref.current?.enterFullscreen() }} /> @@ -89,7 +97,11 @@ function Controls({ }, [player]) const toggleSound = useCallback(() => { - player.muted = !player.muted + const newValue = !player.muted + // We want to set this to the _inverse_ of the new value, because we actually want for the audio to be mixed when + // the video is muted, and vice versa. + PlatformInfo.setAudioMixWithOthers(!newValue) + player.muted = newValue }, [player]) return ( From 5bfe5aa503d73e687ccc048efc5138edea97bea3 Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 9 Aug 2024 14:35:26 -0700 Subject: [PATCH 18/50] [Video] More tweaks to `AVAudioSession` options (#4910) --- modules/expo-bluesky-swiss-army/index.ts | 3 ++- .../PlatformInfo/ExpoPlatformInfoModule.swift | 16 ++++++++++++-- .../src/PlatformInfo/index.native.ts | 7 +++++++ .../src/PlatformInfo/index.ts | 14 +++++++++++++ .../src/PlatformInfo/index.web.ts | 5 +++++ .../src/PlatformInfo/types.ts | 15 +++++++++++++ src/App.native.tsx | 3 ++- .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 21 +++++++++++++------ 8 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts index ebd67913e0..2cf4f36c52 100644 --- a/modules/expo-bluesky-swiss-army/index.ts +++ b/modules/expo-bluesky-swiss-army/index.ts @@ -1,6 +1,7 @@ import * as PlatformInfo from './src/PlatformInfo' +import {AudioCategory} from './src/PlatformInfo/types' import * as Referrer from './src/Referrer' import * as SharedPrefs from './src/SharedPrefs' import VisibilityView from './src/VisibilityView' -export {PlatformInfo, Referrer, SharedPrefs, VisibilityView} +export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView} diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift index ff1148a0b5..471f1438b0 100644 --- a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift +++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift @@ -8,14 +8,26 @@ public class ExpoPlatformInfoModule: Module { return UIAccessibility.isReduceMotionEnabled } + Function("setAudioCategory") { (audioCategoryString: String) in + let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString) + try? AVAudioSession.sharedInstance().setCategory(audioCategory) + } + Function("setAudioMixWithOthers") { (mixWithOthers: Bool) in var options: AVAudioSession.CategoryOptions + let currentCategory = AVAudioSession.sharedInstance().category if mixWithOthers { options = [.mixWithOthers] } else { - options = [] + options = [.duckOthers] } - try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: options) + try? AVAudioSession + .sharedInstance() + .setCategory( + currentCategory, + mode: .default, + options: options + ) } } } 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 949bd78a28..ba9dddf82a 100644 --- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts @@ -1,6 +1,8 @@ import {Platform} from 'react-native' import {requireNativeModule} from 'expo-modules-core' +import {AudioCategory} from './types' + const NativeModule = requireNativeModule('ExpoPlatformInfo') export function getIsReducedMotionEnabled(): boolean { @@ -11,3 +13,8 @@ export function setAudioMixWithOthers(mixWithOthers: boolean): void { if (Platform.OS !== 'ios') return NativeModule.setAudioMixWithOthers(mixWithOthers) } + +export function setAudioCategory(audioCategory: AudioCategory): void { + if (Platform.OS !== 'ios') return + NativeModule.setAudioCategory(audioCategory) +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts index 2665748b0a..5659339fba 100644 --- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts @@ -1,9 +1,23 @@ import {NotImplementedError} from '../NotImplemented' +import {AudioCategory} from './types' export function getIsReducedMotionEnabled(): boolean { throw new NotImplementedError() } +/** + * Set whether the app's audio should mix with other apps' audio. + * @param mixWithOthers + */ export function setAudioMixWithOthers(mixWithOthers: boolean): void { throw new NotImplementedError({mixWithOthers}) } + +/** + * Set the audio category for the app. + * @param audioCategory + * @platform ios + */ +export function setAudioCategory(audioCategory: AudioCategory): void { + throw new NotImplementedError({audioCategory}) +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts index 917c97396f..cb64d00cee 100644 --- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts @@ -1,4 +1,5 @@ import {NotImplementedError} from '../NotImplemented' +import {AudioCategory} from './types' export function getIsReducedMotionEnabled(): boolean { if (typeof window === 'undefined') { @@ -10,3 +11,7 @@ export function getIsReducedMotionEnabled(): boolean { export function setAudioMixWithOthers(mixWithOthers: boolean): void { throw new NotImplementedError({mixWithOthers}) } + +export function setAudioCategory(audioCategory: AudioCategory): void { + throw new NotImplementedError({audioCategory}) +} diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts new file mode 100644 index 0000000000..374f343183 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts @@ -0,0 +1,15 @@ +/** + * Sets the audio session category on iOS. In general, we should only need to use this for the `playback` and `ambient` + * categories. This enum however includes other categories that are available in the native API for clarity and + * potential future use. + * @see https://developer.apple.com/documentation/avfoundation/avaudiosession/category + * @platform ios + */ +export enum AudioCategory { + Ambient = 'AVAudioSessionCategoryAmbient', + Playback = 'AVAudioSessionCategoryPlayback', + _SoloAmbient = 'AVAudioSessionCategorySoloAmbient', + _Record = 'AVAudioSessionCategoryRecord', + _PlayAndRecord = 'AVAudioSessionCategoryPlayAndRecord', + _MultiRoute = 'AVAudioSessionCategoryMultiRoute', +} diff --git a/src/App.native.tsx b/src/App.native.tsx index 29a6a738c5..71b53e7a38 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -61,7 +61,7 @@ import {Provider as PortalProvider} from '#/components/Portal' import {Splash} from '#/Splash' import {Provider as TourProvider} from '#/tours' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' -import {PlatformInfo} from '../modules/expo-bluesky-swiss-army' +import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army' SplashScreen.preventAutoHideAsync() @@ -158,6 +158,7 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { + PlatformInfo.setAudioCategory(AudioCategory.Ambient) PlatformInfo.setAudioMixWithOthers(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 5768662606..83bb2f4238 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -12,7 +12,10 @@ import {android, atoms as a, useTheme} from '#/alf' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' import {Text} from '#/components/Typography' -import {PlatformInfo} from '../../../../../../modules/expo-bluesky-swiss-army' +import { + AudioCategory, + PlatformInfo, +} from '../../../../../../modules/expo-bluesky-swiss-army' export function VideoEmbedInnerNative() { const player = useVideoPlayer() @@ -39,10 +42,12 @@ export function VideoEmbedInnerNative() { style={a.flex_1} nativeControls={true} onEnterFullscreen={() => { + PlatformInfo.setAudioCategory(AudioCategory.Playback) PlatformInfo.setAudioMixWithOthers(false) player.muted = false }} onExitFullscreen={() => { + PlatformInfo.setAudioCategory(AudioCategory.Ambient) PlatformInfo.setAudioMixWithOthers(true) player.muted = true }} @@ -96,12 +101,16 @@ function Controls({ } }, [player]) - const toggleSound = useCallback(() => { - const newValue = !player.muted + const toggleMuted = useCallback(() => { + const muted = !player.muted // We want to set this to the _inverse_ of the new value, because we actually want for the audio to be mixed when // the video is muted, and vice versa. - PlatformInfo.setAudioMixWithOthers(!newValue) - player.muted = newValue + const mix = !muted + const category = muted ? AudioCategory.Ambient : AudioCategory.Playback + + PlatformInfo.setAudioCategory(category) + PlatformInfo.setAudioMixWithOthers(mix) + player.muted = muted }, [player]) return ( @@ -140,7 +149,7 @@ function Controls({ accessibilityRole="button" /> Date: Fri, 9 Aug 2024 23:32:28 +0100 Subject: [PATCH 19/50] animate controls + fade out time after a while (#4913) Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 68 +++++++++++-------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index 83bb2f4238..ae3a4a2297 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useRef, useState} from 'react' import {Pressable, View} from 'react-native' -import Animated, {FadeIn} from 'react-native-reanimated' +import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated' import {VideoPlayer, VideoView} from 'expo-video' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -39,7 +39,7 @@ export function VideoEmbedInnerNative() { { PlatformInfo.setAudioCategory(AudioCategory.Playback) @@ -86,6 +86,7 @@ function Controls({ // 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) @@ -113,11 +114,18 @@ function Controls({ player.muted = muted }, [player]) + // show countdown when: + // 1. timeRemaining is a number - was seeing NaNs + // 2. duration is greater than 0 - means metadata has loaded + // 3. we're less than 5 second into the video + const showTime = !isNaN(timeRemaining) && duration > 0 && currentTime <= 5 + return ( - {!isNaN(timeRemaining) && ( + {showTime && ( - - - {isMuted ? ( - - ) : ( - - )} + {duration > 0 && ( + + + {isMuted ? ( + + ) : ( + + )} + - + )} ) } From ab0da7c892ce7532840417b89a42302ab9db7018 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 9 Aug 2024 23:35:21 +0100 Subject: [PATCH 20/50] [Videos] handle app backgrounding (#4912) * play when returning from background * play when unfullscreening * play when entering fullscreen, just to be sure * state -> ref --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 43 ++++++++++++++++--- 1 file changed, 36 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 ae3a4a2297..33148da01a 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -7,6 +7,7 @@ import {useLingui} from '@lingui/react' import {useIsFocused} from '@react-navigation/native' import {HITSLOP_30} from '#/lib/constants' +import {useAppState} from '#/lib/hooks/useAppState' 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' @@ -21,6 +22,18 @@ export function VideoEmbedInnerNative() { const player = useVideoPlayer() 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(() => { @@ -34,6 +47,10 @@ export function VideoEmbedInnerNative() { } }, [isScreenFocused, player]) + const enterFullscreen = useCallback(() => { + ref.current?.enterFullscreen() + }, []) + return ( - { - ref.current?.enterFullscreen() - }} - /> + ) } @@ -102,6 +115,22 @@ function Controls({ } }, [player]) + const onPressFullscreen = useCallback(() => { + switch (player.status) { + case 'idle': + case 'loading': + case 'readyToPlay': { + if (!player.playing) player.play() + enterFullscreen() + break + } + case 'error': { + player.replay() + break + } + } + }, [player, enterFullscreen]) + const toggleMuted = useCallback(() => { const muted = !player.muted // We want to set this to the _inverse_ of the new value, because we actually want for the audio to be mixed when @@ -150,7 +179,7 @@ function Controls({ )} Date: Sat, 10 Aug 2024 00:49:11 +0100 Subject: [PATCH 21/50] [Videos] Add error boundary to native (#4914) * move error fallback to own component * use error boundary on native --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- src/view/com/util/post-embeds/VideoEmbed.tsx | 71 +++++++++++++------ .../com/util/post-embeds/VideoEmbed.web.tsx | 48 ++----------- .../VideoEmbedInner/VideoFallback.tsx | 61 ++++++++++++++++ 3 files changed, 116 insertions(+), 64 deletions(-) create mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index 887efac1ab..4e2909f40b 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -1,6 +1,6 @@ -import React from 'react' +import React, {useCallback, useState} from 'react' import {View} from 'react-native' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {VideoEmbedInnerNative} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' @@ -8,13 +8,23 @@ 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 {ErrorBoundary} from '../ErrorBoundary' import {useActiveVideoView} from './ActiveVideoContext' +import * as VideoFallback from './VideoEmbedInner/VideoFallback' export function VideoEmbed({source}: {source: string}) { const t = useTheme() const {active, setActive} = useActiveVideoView({source}) const {_} = useLingui() + const [key, setKey] = useState(0) + const renderError = useCallback( + (error: unknown) => ( + setKey(key + 1)} /> + ), + [key], + ) + return ( - { - if (isActive) { - setActive() - } - }}> - {active ? ( - - ) : ( - - )} - + + { + 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 22/50] [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 23/50] 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 24/50] 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 25/50] 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 26/50] 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 27/50] 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 28/50] 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 29/50] 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 30/50] [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 31/50] [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 32/50] 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 33/50] [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 34/50] 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 35/50] 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 36/50] [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 37/50] 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 38/50] [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 39/50] [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 40/50] 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 41/50] [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 42/50] 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 43/50] 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 44/50] 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 45/50] [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 46/50] 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 47/50] 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 48/50] 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 49/50] 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' From 723896a45f0fdf9612e5b6bb2a82ac7e894928ba Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 20 Aug 2024 15:43:40 -0700 Subject: [PATCH 50/50] Add `list hidden` screen (#4958) Co-authored-by: Hailey Co-authored-by: Eric Bailey --- src/components/Error.tsx | 32 +-- src/components/ListCard.tsx | 48 +++- src/components/moderation/Hider.tsx | 89 ++++++++ .../moderation/ModerationDetailsDialog.tsx | 4 +- src/lib/hooks/useGoBack.ts | 23 ++ src/screens/List/ListHiddenScreen.tsx | 216 ++++++++++++++++++ src/state/queries/list.ts | 2 +- src/view/com/lists/ListCard.tsx | 183 --------------- src/view/com/lists/MyLists.tsx | 40 ++-- src/view/com/util/post-embeds/ListEmbed.tsx | 32 --- src/view/com/util/post-embeds/index.tsx | 16 +- src/view/screens/ProfileList.tsx | 148 +++++++----- 12 files changed, 494 insertions(+), 339 deletions(-) create mode 100644 src/components/moderation/Hider.tsx create mode 100644 src/lib/hooks/useGoBack.ts create mode 100644 src/screens/List/ListHiddenScreen.tsx delete mode 100644 src/view/com/lists/ListCard.tsx delete mode 100644 src/view/com/util/post-embeds/ListEmbed.tsx diff --git a/src/components/Error.tsx b/src/components/Error.tsx index 481532434a..59d219831c 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -2,21 +2,18 @@ import React from 'react' import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/core' -import {StackActions} from '@react-navigation/native' -import {NavigationProp} from 'lib/routes/types' +import {useGoBack} from 'lib/hooks/useGoBack' import {CenteredView} from 'view/com/util/Views' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {Text} from '#/components/Typography' -import {router} from '#/routes' export function Error({ title, message, onRetry, - onGoBack: onGoBackProp, + onGoBack, hideBackButton, sideBorders = true, }: { @@ -27,31 +24,10 @@ export function Error({ hideBackButton?: boolean sideBorders?: boolean }) { - const navigation = useNavigation() const {_} = useLingui() const t = useTheme() const {gtMobile} = useBreakpoints() - - const canGoBack = navigation.canGoBack() - const onGoBack = React.useCallback(() => { - if (onGoBackProp) { - onGoBackProp() - return - } - if (canGoBack) { - navigation.goBack() - } else { - navigation.navigate('HomeTab') - - // Checking the state for routes ensures that web doesn't encounter errors while going back - if (navigation.getState()?.routes) { - navigation.dispatch(StackActions.push(...router.matchPath('/'))) - } else { - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - } - } - }, [navigation, canGoBack, onGoBackProp]) + const goBack = useGoBack(onGoBack) return ( diff --git a/src/components/ListCard.tsx b/src/components/ListCard.tsx index 0ed27cf50f..829f36d471 100644 --- a/src/components/ListCard.tsx +++ b/src/components/ListCard.tsx @@ -1,13 +1,20 @@ import React from 'react' import {View} from 'react-native' -import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api' +import { + AppBskyActorDefs, + AppBskyGraphDefs, + AtUri, + moderateUserList, + ModerationUI, +} from '@atproto/api' import {Trans} from '@lingui/macro' import {useQueryClient} from '@tanstack/react-query' import {sanitizeHandle} from 'lib/strings/handles' +import {useModerationOpts} from 'state/preferences/moderation-opts' import {precacheList} from 'state/queries/feed' -import {useTheme} from '#/alf' -import {atoms as a} from '#/alf' +import {useSession} from 'state/session' +import {atoms as a, useTheme} from '#/alf' import { Avatar, Description, @@ -16,6 +23,7 @@ import { SaveButton, } from '#/components/FeedCard' import {Link as InternalLink, LinkProps} from '#/components/Link' +import * as Hider from '#/components/moderation/Hider' import {Text} from '#/components/Typography' /* @@ -43,6 +51,11 @@ type Props = { export function Default(props: Props) { const {view, showPinButton} = props + const moderationOpts = useModerationOpts() + const moderation = moderationOpts + ? moderateUserList(view, moderationOpts) + : undefined + return ( @@ -52,6 +65,7 @@ export function Default(props: Props) { title={view.name} creator={view.creator} purpose={view.purpose} + modUi={moderation?.ui('contentView')} /> {showPinButton && view.purpose === CURATELIST && ( @@ -89,18 +103,40 @@ export function TitleAndByline({ title, creator, purpose = CURATELIST, + modUi, }: { title: string creator?: AppBskyActorDefs.ProfileViewBasic purpose?: AppBskyGraphDefs.ListView['purpose'] + modUi?: ModerationUI }) { const t = useTheme() + const {currentAccount} = useSession() return ( - - {title} - + + + + Hidden list + + + + + {title} + + + + {creator && ( void + info: ModerationCauseDescription + showInfoDialog: () => void + meta: { + isNoPwi: boolean + allowOverride: boolean + } +} + +const Context = React.createContext({} as Context) + +export const useHider = () => React.useContext(Context) + +export function Outer({ + modui, + isContentVisibleInitialState, + allowOverride, + children, +}: React.PropsWithChildren<{ + isContentVisibleInitialState?: boolean + allowOverride?: boolean + modui: ModerationUI | undefined +}>) { + const control = useModerationDetailsDialogControl() + const blur = modui?.blurs[0] + const [isContentVisible, setIsContentVisible] = React.useState( + isContentVisibleInitialState || !blur, + ) + const info = useModerationCauseDescription(blur) + + const meta = { + isNoPwi: Boolean( + modui?.blurs.find( + cause => + cause.type === 'label' && + cause.labelDef.identifier === '!no-unauthenticated', + ), + ), + allowOverride: allowOverride ?? !modui?.noOverride, + } + + const showInfoDialog = () => { + control.open() + } + + const onSetContentVisible = (show: boolean) => { + if (meta.allowOverride) return + setIsContentVisible(show) + } + + const ctx = { + isContentVisible, + setIsContentVisible: onSetContentVisible, + showInfoDialog, + info, + meta, + } + + return ( + + {children} + + + ) +} + +export function Content({children}: {children: React.ReactNode}) { + const ctx = useHider() + return ctx.isContentVisible ? children : null +} + +export function Mask({children}: {children: React.ReactNode}) { + const ctx = useHider() + return ctx.isContentVisible ? null : children +} diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx index ebfe452325..b8f02582c6 100644 --- a/src/components/moderation/ModerationDetailsDialog.tsx +++ b/src/components/moderation/ModerationDetailsDialog.tsx @@ -18,7 +18,7 @@ export {useDialogControl as useModerationDetailsDialogControl} from '#/component export interface ModerationDetailsDialogProps { control: Dialog.DialogOuterProps['control'] - modcause: ModerationCause + modcause?: ModerationCause } export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) { @@ -123,7 +123,7 @@ function ModerationDetailsDialogInner({ {description} - {modcause.type === 'label' && ( + {modcause?.type === 'label' && ( <> diff --git a/src/lib/hooks/useGoBack.ts b/src/lib/hooks/useGoBack.ts new file mode 100644 index 0000000000..59555bdac1 --- /dev/null +++ b/src/lib/hooks/useGoBack.ts @@ -0,0 +1,23 @@ +import {StackActions, useNavigation} from '@react-navigation/native' + +import {NavigationProp} from 'lib/routes/types' +import {router} from '#/routes' + +export function useGoBack(onGoBack?: () => unknown) { + const navigation = useNavigation() + return () => { + onGoBack?.() + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('HomeTab') + // Checking the state for routes ensures that web doesn't encounter errors while going back + if (navigation.getState()?.routes) { + navigation.dispatch(StackActions.push(...router.matchPath('/'))) + } else { + navigation.navigate('HomeTab') + navigation.dispatch(StackActions.popToTop()) + } + } + } +} diff --git a/src/screens/List/ListHiddenScreen.tsx b/src/screens/List/ListHiddenScreen.tsx new file mode 100644 index 0000000000..473bb08ea4 --- /dev/null +++ b/src/screens/List/ListHiddenScreen.tsx @@ -0,0 +1,216 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyGraphDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {RQKEY_ROOT as listQueryRoot} from '#/state/queries/list' +import {useGoBack} from 'lib/hooks/useGoBack' +import {sanitizeHandle} from 'lib/strings/handles' +import {useListBlockMutation, useListMuteMutation} from 'state/queries/list' +import { + UsePreferencesQueryResponse, + useRemoveFeedMutation, +} from 'state/queries/preferences' +import {useSession} from 'state/session' +import * as Toast from 'view/com/util/Toast' +import {CenteredView} from 'view/com/util/Views' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {Loader} from '#/components/Loader' +import {useHider} from '#/components/moderation/Hider' +import {Text} from '#/components/Typography' + +export function ListHiddenScreen({ + list, + preferences, +}: { + list: AppBskyGraphDefs.ListView + preferences: UsePreferencesQueryResponse +}) { + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + const {gtMobile} = useBreakpoints() + const isOwner = currentAccount?.did === list.creator.did + const goBack = useGoBack() + const queryClient = useQueryClient() + + const isModList = list.purpose === AppBskyGraphDefs.MODLIST + + const [isProcessing, setIsProcessing] = React.useState(false) + const listBlockMutation = useListBlockMutation() + const listMuteMutation = useListMuteMutation() + const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation() + + const {setIsContentVisible} = useHider() + + const savedFeedConfig = preferences.savedFeeds.find(f => f.value === list.uri) + + const onUnsubscribe = async () => { + setIsProcessing(true) + if (list.viewer?.muted) { + try { + await listMuteMutation.mutateAsync({uri: list.uri, mute: false}) + } catch (e) { + setIsProcessing(false) + logger.error('Failed to unmute list', {message: e}) + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + return + } + } + if (list.viewer?.blocked) { + try { + await listBlockMutation.mutateAsync({uri: list.uri, block: false}) + } catch (e) { + setIsProcessing(false) + logger.error('Failed to unblock list', {message: e}) + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + return + } + } + queryClient.invalidateQueries({ + queryKey: [listQueryRoot], + }) + Toast.show(_(msg`Unsubscribed from list`)) + setIsProcessing(false) + } + + const onRemoveList = async () => { + if (!savedFeedConfig) return + try { + await removeSavedFeed(savedFeedConfig) + Toast.show(_(msg`Removed from saved feeds`)) + } catch (e) { + logger.error('Failed to remove list from saved feeds', {message: e}) + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } finally { + setIsProcessing(false) + } + } + + return ( + + + + + + List has been hidden + + + + This list - created by{' '} + + {isOwner + ? _(msg`you`) + : sanitizeHandle(list.creator.handle, '@')} + {' '} + - contains possible violations of Bluesky's community guidelines + in its name or description. + + + + + + + {savedFeedConfig ? ( + + ) : null} + {isOwner ? ( + + ) : list.viewer?.muted || list.viewer?.blocked ? ( + + ) : null} + + + + + ) +} diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index eeb9c3b381..405cb4ae3e 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -17,7 +17,7 @@ import {useAgent, useSession} from '../session' import {invalidate as invalidateMyLists} from './my-lists' import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists' -const RQKEY_ROOT = 'list' +export const RQKEY_ROOT = 'list' export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] export function useListQuery(uri?: string) { diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx deleted file mode 100644 index 587885502b..0000000000 --- a/src/view/com/lists/ListCard.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {AppBskyGraphDefs, AtUri, RichText} from '@atproto/api' -import {Trans} from '@lingui/macro' - -import {useSession} from '#/state/session' -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 {s} from 'lib/styles' -import {atoms as a} from '#/alf' -import {RichText as RichTextCom} from '#/components/RichText' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' -import {UserAvatar} from '../util/UserAvatar' - -export const ListCard = ({ - testID, - list, - noBg, - noBorder, - renderButton, - style, -}: { - testID?: string - list: AppBskyGraphDefs.ListView - noBg?: boolean - noBorder?: boolean - renderButton?: () => JSX.Element - style?: StyleProp -}) => { - const pal = usePalette('default') - const {currentAccount} = useSession() - - const rkey = React.useMemo(() => { - try { - const urip = new AtUri(list.uri) - return urip.rkey - } catch { - return '' - } - }, [list]) - - const descriptionRichText = React.useMemo(() => { - if (list.description) { - return new RichText({ - text: list.description, - facets: list.descriptionFacets, - }) - } - return undefined - }, [list]) - - return ( - - - - - - - - {sanitizeDisplayName(list.name)} - - - {list.purpose === 'app.bsky.graph.defs#curatelist' && - (list.creator.did === currentAccount?.did ? ( - User list by you - ) : ( - - User list by {sanitizeHandle(list.creator.handle, '@')} - - ))} - {list.purpose === 'app.bsky.graph.defs#modlist' && - (list.creator.did === currentAccount?.did ? ( - Moderation list by you - ) : ( - - Moderation list by {sanitizeHandle(list.creator.handle, '@')} - - ))} - - - {list.viewer?.muted ? ( - - - Muted - - - ) : null} - - {list.viewer?.blocked ? ( - - - Blocked - - - ) : null} - - - {renderButton ? ( - {renderButton()} - ) : undefined} - - {descriptionRichText ? ( - - - - ) : undefined} - - ) -} - -const styles = StyleSheet.create({ - outer: { - borderTopWidth: StyleSheet.hairlineWidth, - paddingHorizontal: 6, - }, - outerNoBorder: { - borderTopWidth: 0, - }, - layout: { - flexDirection: 'row', - alignItems: 'center', - }, - layoutAvi: { - width: 54, - paddingLeft: 4, - paddingTop: 8, - paddingBottom: 10, - }, - avi: { - width: 40, - height: 40, - borderRadius: 20, - resizeMode: 'cover', - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, - layoutButton: { - paddingRight: 10, - }, - details: { - paddingLeft: 54, - paddingRight: 10, - paddingBottom: 10, - }, - pill: { - borderRadius: 4, - paddingHorizontal: 6, - paddingVertical: 2, - }, - btn: { - paddingVertical: 7, - borderRadius: 50, - marginLeft: 6, - paddingHorizontal: 14, - }, -}) diff --git a/src/view/com/lists/MyLists.tsx b/src/view/com/lists/MyLists.tsx index 472d2688c7..b56fa6c75f 100644 --- a/src/view/com/lists/MyLists.tsx +++ b/src/view/com/lists/MyLists.tsx @@ -4,7 +4,6 @@ import { FlatList as RNFlatList, RefreshControl, StyleProp, - StyleSheet, View, ViewStyle, } from 'react-native' @@ -18,10 +17,13 @@ import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' +import {isWeb} from 'platform/detection' +import {useModerationOpts} from 'state/preferences/moderation-opts' import {EmptyState} from 'view/com/util/EmptyState' +import {atoms as a, useTheme} from '#/alf' +import * as ListCard from '#/components/ListCard' import {ErrorMessage} from '../util/error/ErrorMessage' import {List} from '../util/List' -import {ListCard} from './ListCard' const LOADING = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'} @@ -41,8 +43,10 @@ export function MyLists({ testID?: string }) { const pal = usePalette('default') + const t = useTheme() const {track} = useAnalytics() const {_} = useLingui() + const moderationOpts = useModerationOpts() const [isPTRing, setIsPTRing] = React.useState(false) const {data, isFetching, isFetched, isError, error, refetch} = useMyListsQuery(filter) @@ -53,7 +57,7 @@ export function MyLists({ if (isError && isEmpty) { items = items.concat([ERROR_ITEM]) } - if (!isFetched && isFetching) { + if ((!isFetched && isFetching) || !moderationOpts) { items = items.concat([LOADING]) } else if (isEmpty) { items = items.concat([EMPTY]) @@ -61,7 +65,7 @@ export function MyLists({ items = items.concat(data) } return items - }, [isError, isEmpty, isFetched, isFetching, data]) + }, [isError, isEmpty, isFetched, isFetching, moderationOpts, data]) // events // = @@ -85,7 +89,6 @@ export function MyLists({ if (item === EMPTY) { return ( ) } else if (item === LOADING) { return ( - + ) @@ -109,15 +111,18 @@ export function MyLists({ return renderItem ? ( renderItem(item, index) ) : ( - + + + ) }, - [error, onRefresh, renderItem, _], + [renderItem, t.atoms.border_contrast_low, _, error, onRefresh], ) if (inline) { @@ -166,10 +171,3 @@ export function MyLists({ ) } } - -const styles = StyleSheet.create({ - item: { - paddingHorizontal: 18, - paddingVertical: 4, - }, -}) diff --git a/src/view/com/util/post-embeds/ListEmbed.tsx b/src/view/com/util/post-embeds/ListEmbed.tsx deleted file mode 100644 index fc5ad270fc..0000000000 --- a/src/view/com/util/post-embeds/ListEmbed.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {ListCard} from 'view/com/lists/ListCard' -import {AppBskyGraphDefs} from '@atproto/api' -import {s} from 'lib/styles' - -export function ListEmbed({ - item, - style, -}: { - item: AppBskyGraphDefs.ListView - style?: StyleProp -}) { - const pal = usePalette('default') - - return ( - - - - ) -} - -const styles = StyleSheet.create({ - container: { - borderRadius: 8, - }, - card: { - borderTopWidth: 0, - borderRadius: 8, - }, -}) diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 0462212fbd..9c13644834 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -25,13 +25,13 @@ import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePalette} from 'lib/hooks/usePalette' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' +import * as ListCard from '#/components/ListCard' import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {ContentHider} from '../../../../components/moderation/ContentHider' import {AutoSizedImage} from '../images/AutoSizedImage' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' import {ExternalLinkEmbed} from './ExternalLinkEmbed' -import {ListEmbed} from './ListEmbed' import {MaybeQuoteEmbed} from './QuoteEmbed' type Embed = @@ -203,10 +203,20 @@ function MaybeListCard({view}: {view: AppBskyGraphDefs.ListView}) { const moderation = React.useMemo(() => { return moderationOpts ? moderateUserList(view, moderationOpts) : undefined }, [view, moderationOpts]) + const t = useTheme() return ( - + + + ) } diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index bf13791ae6..0c2c6405fc 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -32,6 +32,7 @@ import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import { useAddSavedFeedsMutation, usePreferencesQuery, + UsePreferencesQueryResponse, useRemoveFeedMutation, useUpdateSavedFeedsMutation, } from '#/state/queries/preferences' @@ -67,9 +68,10 @@ import {LoadingScreen} from 'view/com/util/LoadingScreen' import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' import {CenteredView} from 'view/com/util/Views' +import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen' import {atoms as a, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' -import {ScreenHider} from '#/components/moderation/ScreenHider' +import * as Hider from '#/components/moderation/Hider' import * as Prompt from '#/components/Prompt' import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {RichText} from '#/components/RichText' @@ -88,6 +90,7 @@ export function ProfileListScreen(props: Props) { const {data: resolvedUri, error: resolveError} = useResolveUriQuery( AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), ) + const {data: preferences} = usePreferencesQuery() const {data: list, error: listError} = useListQuery(resolvedUri?.uri) const moderationOpts = useModerationOpts() @@ -110,12 +113,13 @@ export function ProfileListScreen(props: Props) { ) } - return resolvedUri && list && moderationOpts ? ( + return resolvedUri && list && moderationOpts && preferences ? ( ) : ( @@ -127,27 +131,32 @@ function ProfileListScreenLoaded({ uri, list, moderationOpts, + preferences, }: Props & { uri: string list: AppBskyGraphDefs.ListView moderationOpts: ModerationOpts + preferences: UsePreferencesQueryResponse }) { const {_} = useLingui() const queryClient = useQueryClient() const {openComposer} = useComposerControls() const setMinimalShellMode = useSetMinimalShellMode() + const {currentAccount} = useSession() const {rkey} = route.params const feedSectionRef = React.useRef(null) const aboutSectionRef = React.useRef(null) const {openModal} = useModalControls() - const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST const isScreenFocused = useIsFocused() + const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1 + const isOwner = currentAccount?.did === list.creator.did const moderation = React.useMemo(() => { return moderateUserList(list, moderationOpts) }, [list, moderationOpts]) - useSetTitle(list.name) + useSetTitle(isHidden ? _(msg`List Hidden`) : list.name) useFocusEffect( useCallback(() => { @@ -179,34 +188,75 @@ function ProfileListScreenLoaded({ ) const renderHeader = useCallback(() => { - return
- }, [rkey, list]) + return
+ }, [rkey, list, preferences]) if (isCurateList) { return ( - + + + + + + + + {({headerHeight, scrollElRef, isFocused}) => ( + + )} + {({headerHeight, scrollElRef}) => ( + + )} + + openComposer({})} + icon={ + + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + + + + ) + } + return ( + + + + + - {({headerHeight, scrollElRef, isFocused}) => ( - - )} + renderHeader={renderHeader}> {({headerHeight, scrollElRef}) => ( @@ -227,47 +277,20 @@ function ProfileListScreenLoaded({ accessibilityHint="" /> - - ) - } - return ( - - - - {({headerHeight, scrollElRef}) => ( - - )} - - openComposer({})} - icon={ - - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - - + + ) } -function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { +function Header({ + rkey, + list, + preferences, +}: { + rkey: string + list: AppBskyGraphDefs.ListView + preferences: UsePreferencesQueryResponse +}) { const pal = usePalette('default') const palInverted = usePalette('inverted') const {_} = useLingui() @@ -283,7 +306,6 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const isBlocking = !!list.viewer?.blocked const isMuting = !!list.viewer?.muted const isOwner = list.creator.did === currentAccount?.did - const {data: preferences} = usePreferencesQuery() const {track} = useAnalytics() const playHaptic = useHaptics() @@ -644,7 +666,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { cid: list.cid, }} /> - {isCurateList || isPinned ? ( + {isCurateList ? (