diff --git a/__e2e__/flows/curate-lists.yml b/__e2e__/flows/curate-lists.yml index e497898b27..a37bc07778 100644 --- a/__e2e__/flows/curate-lists.yml +++ b/__e2e__/flows/curate-lists.yml @@ -132,7 +132,8 @@ appId: xyz.blueskyweb.app id: "feedItem-by-bob.test" - tapOn: id: "e2eGotoFeeds" -- tapOn: "Good Ppl" +- tapOn: + id: "saved-feed-Good Ppl" - assertVisible: id: "feedItem-by-bob.test" - tapOn: @@ -168,7 +169,7 @@ appId: xyz.blueskyweb.app id: "profilePager-selector" direction: LEFT - tapOn: - id: "profilePager-selector-5" + id: "profilePager-selector-6" - tapOn: "Good Ppl" - tapOn: diff --git a/assets/icons/play_filled_corner2_rounded.svg b/assets/icons/play_filled_corner2_rounded.svg new file mode 100644 index 0000000000..e25e8d4628 --- /dev/null +++ b/assets/icons/play_filled_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/play_stroke2_corner2_rounded.svg b/assets/icons/play_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..54bba91fb9 --- /dev/null +++ b/assets/icons/play_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index d7e41a4ca8..61a524a70b 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -197,6 +197,7 @@ func serve(cctx *cli.Context) error { e.GET("/search", server.WebGeneric) e.GET("/feeds", server.WebGeneric) e.GET("/notifications", server.WebGeneric) + e.GET("/notifications/settings", server.WebGeneric) e.GET("/lists", server.WebGeneric) e.GET("/moderation", server.WebGeneric) e.GET("/moderation/modlists", server.WebGeneric) diff --git a/package.json b/package.json index b56dd9e39b..91b427ae91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.89.0", + "version": "1.90.0", "private": true, "engines": { "node": ">=18" @@ -52,7 +52,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "^0.12.23", + "@atproto/api": "0.12.25", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", @@ -143,6 +143,7 @@ "expo-web-browser": "~13.0.3", "fast-text-encoding": "^1.0.6", "history": "^5.3.0", + "hls.js": "^1.5.11", "js-sha256": "^0.9.0", "jwt-decode": "^4.0.0", "lande": "^1.0.10", @@ -269,7 +270,7 @@ "react-scripts": "^5.0.1", "react-test-renderer": "18.2.0", "ts-node": "^10.9.1", - "typescript": "^5.3.3", + "typescript": "^5.5.4", "url-loader": "^4.1.1", "webpack": "^5.75.0", "webpack-bundle-analyzer": "^4.10.1", diff --git a/patches/expo-modules-core+1.12.11.patch b/patches/expo-modules-core+1.12.11.patch index 4878bb9f7e..a4ee027c81 100644 --- a/patches/expo-modules-core+1.12.11.patch +++ b/patches/expo-modules-core+1.12.11.patch @@ -12,3 +12,15 @@ index bb74e80..0aa0202 100644 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 +--- a/node_modules/expo-modules-core/build/uuid/uuid.js ++++ b/node_modules/expo-modules-core/build/uuid/uuid.js +@@ -1,5 +1,7 @@ + import bytesToUuid from './lib/bytesToUuid'; + import { Uuidv5Namespace } from './uuid.types'; ++import { ensureNativeModulesAreInstalled } from '../ensureNativeModulesAreInstalled'; ++ensureNativeModulesAreInstalled(); + const nativeUuidv4 = globalThis?.expo?.uuidv4; + const nativeUuidv5 = globalThis?.expo?.uuidv5; + function uuidv4() { diff --git a/patches/expo-video+1.1.10.patch b/patches/expo-video+1.1.10.patch new file mode 100644 index 0000000000..b183be9d41 --- /dev/null +++ b/patches/expo-video+1.1.10.patch @@ -0,0 +1,20 @@ +--- 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/src/App.native.tsx b/src/App.native.tsx index ed76c753b5..d2c20fc8e7 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -23,10 +23,12 @@ import { } from '#/lib/statsig/statsig' import {s} from '#/lib/styles' import {ThemeProvider} from '#/lib/ThemeContext' +import I18nProvider from '#/locale/i18nProvider' import {logger} from '#/logger' import {Provider as A11yProvider} from '#/state/a11y' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' +import {listenSessionDropped} from '#/state/events' import {Provider as InvitesStateProvider} from '#/state/invites' import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' @@ -49,6 +51,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {TestCtrls} from '#/view/com/testing/TestCtrls' +import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' import * as Toast from '#/view/com/util/Toast' import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' @@ -58,8 +61,6 @@ 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 I18nProvider from './locale/i18nProvider' -import {listenSessionDropped} from './state/events' SplashScreen.preventAutoHideAsync() @@ -107,42 +108,44 @@ function InnerApp() { - - - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index a64988f380..df6fbf2449 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -12,10 +12,12 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler' import {QueryProvider} from '#/lib/react-query' import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {ThemeProvider} from '#/lib/ThemeContext' +import I18nProvider from '#/locale/i18nProvider' import {logger} from '#/logger' import {Provider as A11yProvider} from '#/state/a11y' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' +import {listenSessionDropped} from '#/state/events' import {Provider as InvitesStateProvider} from '#/state/invites' import {Provider as LightboxStateProvider} from '#/state/lightbox' import {MessagesProvider} from '#/state/messages' @@ -37,6 +39,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' +import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' import * as Toast from '#/view/com/util/Toast' import {ToastContainer} from '#/view/com/util/Toast.web' import {Shell} from '#/view/shell/index' @@ -46,8 +49,6 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as PortalProvider} from '#/components/Portal' import {Provider as TourProvider} from '#/tours' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' -import I18nProvider from './locale/i18nProvider' -import {listenSessionDropped} from './state/events' function InnerApp() { const [isReady, setIsReady] = React.useState(false) @@ -92,39 +93,41 @@ function InnerApp() { - - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 9e9b49443a..8646577c8b 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -76,6 +76,7 @@ import {LogScreen} from './view/screens/Log' import {ModerationModlistsScreen} from './view/screens/ModerationModlists' import {NotFoundScreen} from './view/screens/NotFound' import {NotificationsScreen} from './view/screens/Notifications' +import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings' import {PostLikedByScreen} from './view/screens/PostLikedBy' import {PostRepostedByScreen} from './view/screens/PostRepostedBy' import {PostThreadScreen} from './view/screens/PostThread' @@ -324,6 +325,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => MessagesSettingsScreen} options={{title: title(msg`Chat settings`), requireAuth: true}} /> + NotificationsSettingsScreen} + options={{title: title(msg`Notification settings`), requireAuth: true}} + /> FeedsScreen} diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 650997c919..e918e370d8 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -2,7 +2,6 @@ import {Platform, StyleSheet} from 'react-native' import * as tokens from '#/alf/tokens' import {native, web} from '#/alf/util/platform' -import hairlineWidth = StyleSheet.hairlineWidth export const atoms = { /* @@ -284,19 +283,19 @@ export const atoms = { borderWidth: 0, }, border: { - borderWidth: hairlineWidth, + borderWidth: StyleSheet.hairlineWidth, }, border_t: { - borderTopWidth: hairlineWidth, + borderTopWidth: StyleSheet.hairlineWidth, }, border_b: { - borderBottomWidth: hairlineWidth, + borderBottomWidth: StyleSheet.hairlineWidth, }, border_l: { - borderLeftWidth: hairlineWidth, + borderLeftWidth: StyleSheet.hairlineWidth, }, border_r: { - borderRightWidth: hairlineWidth, + borderRightWidth: StyleSheet.hairlineWidth, }, /* diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 457164d111..4fe0ab4b12 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -70,6 +70,9 @@ export type ButtonProps = Pick< AccessibilityProps & VariantProps & { testID?: string + /** + * For a11y, try to make this descriptive and clear + */ label: string style?: StyleProp hoverStyle?: StyleProp diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index 82d675a8e4..e6d664cfda 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -40,7 +40,7 @@ type Props = { export function Default(props: Props) { const {view} = props return ( - +
@@ -58,7 +58,7 @@ export function Link({ view, children, ...props -}: Props & Omit) { +}: Props & Omit) { const queryClient = useQueryClient() const href = React.useMemo(() => { @@ -70,7 +70,11 @@ export function Link({ }, [view, queryClient]) return ( - + {children} ) diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 53c1fac0b9..2e8724143d 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -190,7 +190,7 @@ export function SuggestedFollows() { {profiles.slice(0, maxLength).map(profile => ( { logEvent('feed:interstitial:profileCard:press', {}) }} @@ -266,7 +266,10 @@ export function SuggestedFollows() { a.pt_xs, a.gap_md, ]}> - + Browse more suggestions @@ -396,7 +399,10 @@ export function SuggestedFeeds() { a.pt_xs, a.gap_md, ]}> - + Browse more suggestions diff --git a/src/components/Link.tsx b/src/components/Link.tsx index a8b478be78..6c25faffb8 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -40,11 +40,6 @@ type BaseLinkProps = Pick< > & { testID?: string - /** - * Label for a11y. Defaults to the href. - */ - label?: string - /** * The React Navigation `StackAction` to perform when the link is pressed. */ @@ -197,7 +192,7 @@ export function useLink({ } export type LinkProps = Omit & - Omit + Omit /** * A interactive element that renders as a `` tag on the web. On mobile it @@ -224,7 +219,6 @@ export function Link({ return ( - + {state => ( - + {state => ( - + {state => ( Adult content can only be enabled via the Web at{' '} { evt.preventDefault() @@ -569,7 +579,9 @@ function PwiOptOut() { - + Learn more about what is public on Bluesky. diff --git a/src/screens/Signup/StepInfo/Policies.tsx b/src/screens/Signup/StepInfo/Policies.tsx index f25bda274f..a3a0672223 100644 --- a/src/screens/Signup/StepInfo/Policies.tsx +++ b/src/screens/Signup/StepInfo/Policies.tsx @@ -45,14 +45,20 @@ export const Policies = ({ const els = [] if (tos) { els.push( - + {_(msg`Terms of Service`)} , ) } if (pp) { els.push( - + {_(msg`Privacy Policy`)} , ) diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index da0383884b..189760460e 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -166,6 +166,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { Having trouble?{' '} Contact support diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 17ee90929c..3cafcb7168 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -46,11 +46,14 @@ const PAGE_SIZE = 30 type RQPageParam = string | undefined const RQKEY_ROOT = 'notification-feed' -export function RQKEY() { - return [RQKEY_ROOT] +export function RQKEY(priority?: false) { + return [RQKEY_ROOT, priority] } -export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { +export function useNotificationFeedQuery(opts?: { + enabled?: boolean + overridePriorityNotifications?: boolean +}) { const agent = useAgent() const queryClient = useQueryClient() const moderationOpts = useModerationOpts() @@ -59,6 +62,10 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { const lastPageCountRef = useRef(0) const gate = useGate() + // false: force showing all notifications + // undefined: let the server decide + const priority = opts?.overridePriorityNotifications ? false : undefined + const query = useInfiniteQuery< FeedPage, Error, @@ -67,7 +74,7 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { RQPageParam >({ staleTime: STALE.INFINITY, - queryKey: RQKEY(), + queryKey: RQKEY(priority), async queryFn({pageParam}: {pageParam: RQPageParam}) { let page if (!pageParam) { @@ -75,17 +82,17 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { page = unreads.getCachedUnreadPage() } if (!page) { - page = ( - await fetchPage({ - agent, - limit: PAGE_SIZE, - cursor: pageParam, - queryClient, - moderationOpts, - fetchAdditionalData: true, - shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'), - }) - ).page + const {page: fetchedPage} = await fetchPage({ + agent, + limit: PAGE_SIZE, + cursor: pageParam, + queryClient, + moderationOpts, + fetchAdditionalData: true, + shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'), + priority, + }) + page = fetchedPage } // if the first page has an unread, mark all read diff --git a/src/state/queries/notifications/settings.ts b/src/state/queries/notifications/settings.ts new file mode 100644 index 0000000000..bfc449d17b --- /dev/null +++ b/src/state/queries/notifications/settings.ts @@ -0,0 +1,69 @@ +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {until} from '#/lib/async/until' +import {logger} from '#/logger' +import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed' +import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread' +import {useAgent} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' + +export function useNotificationsSettingsMutation() { + const {_} = useLingui() + const agent = useAgent() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (keys: string[]) => { + const enabled = keys[0] === 'enabled' + + await agent.api.app.bsky.notification.putPreferences({ + priority: enabled, + }) + + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + res => res.data.priority === enabled, + () => agent.api.app.bsky.notification.listNotifications({limit: 1}), + ) + + eagerlySetCachedPriority(queryClient, enabled) + }, + onError: err => { + logger.error('Failed to save notification preferences', { + safeMessage: err, + }) + Toast.show( + _(msg`Failed to save notification preferences, please try again`), + 'xmark', + ) + }, + onSuccess: () => { + Toast.show(_(msg`Preference saved`)) + }, + onSettled: () => { + invalidateCachedUnreadPage() + queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()}) + }, + }) +} + +function eagerlySetCachedPriority( + queryClient: ReturnType, + enabled: boolean, +) { + queryClient.setQueryData(RQKEY_NOTIFS(), (old: any) => { + if (!old) return old + return { + ...old, + pages: old.pages.map((page: any) => { + return { + ...page, + priority: enabled, + } + }), + } + }) +} diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts index d40a07b12f..c96374eb8e 100644 --- a/src/state/queries/notifications/types.ts +++ b/src/state/queries/notifications/types.ts @@ -22,6 +22,7 @@ export interface FeedPage { cursor: string | undefined seenAt: Date items: FeedNotification[] + priority: boolean } export interface CachedFeedPage { diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index 2f2c242d82..7651e414a4 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -39,10 +39,15 @@ export async function fetchPage({ moderationOpts: ModerationOpts | undefined fetchAdditionalData: boolean shouldUngroupFollowBacks?: () => boolean -}): Promise<{page: FeedPage; indexedAt: string | undefined}> { + priority?: boolean +}): Promise<{ + page: FeedPage + indexedAt: string | undefined +}> { const res = await agent.listNotifications({ limit, cursor, + // priority, }) const indexedAt = res.data.notifications[0]?.indexedAt @@ -88,6 +93,7 @@ export async function fetchPage({ cursor: res.data.cursor, seenAt, items: notifsGrouped, + priority: res.data.priority ?? false, }, indexedAt, } diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 315c9cfadd..569c85c3ad 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -31,7 +31,7 @@ import {LikesFeedAPI} from 'lib/api/feed/likes' import {ListFeedAPI} from 'lib/api/feed/list' import {MergeFeedAPI} from 'lib/api/feed/merge' import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types' -import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip' +import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip' import {BSKY_FEED_OWNER_DIDS} from 'lib/constants' import {KnownError} from '#/view/com/posts/FeedErrorMessage' import {useFeedTuners} from '../preferences/feed-tuners' @@ -61,7 +61,6 @@ export type FeedDescriptor = | `list|${ListUri}` | `list|${ListUri}|${ListFilter}` export interface FeedParams { - disableTuner?: boolean mergeFeedEnabled?: boolean mergeFeedSources?: string[] } @@ -78,7 +77,10 @@ export interface FeedPostSliceItem { uri: string post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record - reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource + reason?: + | AppBskyFeedDefs.ReasonRepost + | ReasonFeedSource + | {[k: string]: unknown; $type: string} feedContext: string | undefined moderation: ModerationDecision parentAuthor?: AppBskyActorDefs.ProfileViewBasic @@ -102,7 +104,7 @@ export interface FeedPageUnselected { export interface FeedPage { api: FeedAPI - tuner: FeedTuner | NoopFeedTuner + tuner: FeedTuner cursor: string | undefined slices: FeedPostSlice[] fetchedAt: number @@ -139,18 +141,11 @@ export function usePostFeedQuery( const selectArgs = React.useMemo( () => ({ feedTuners, - disableTuner: params?.disableTuner, moderationOpts, ignoreFilterFor: opts?.ignoreFilterFor, isDiscover, }), - [ - feedTuners, - params?.disableTuner, - moderationOpts, - opts?.ignoreFilterFor, - isDiscover, - ], + [feedTuners, moderationOpts, opts?.ignoreFilterFor, isDiscover], ) const query = useInfiniteQuery< @@ -229,17 +224,10 @@ export function usePostFeedQuery( (data: InfiniteData) => { // If the selection depends on some data, that data should // be included in the selectArgs object and read here. - const { - feedTuners, - disableTuner, - moderationOpts, - ignoreFilterFor, - isDiscover, - } = selectArgs + const {feedTuners, moderationOpts, ignoreFilterFor, isDiscover} = + selectArgs - const tuner = disableTuner - ? new NoopFeedTuner() - : new FeedTuner(feedTuners) + const tuner = new FeedTuner(feedTuners) // Keep track of the last run and whether we can reuse // some already selected pages from there. @@ -311,7 +299,7 @@ export function usePostFeedQuery( if (isDiscover) { userActionHistory.seen( slice.items.map(item => ({ - feedContext: item.feedContext, + feedContext: slice.feedContext, likeCount: item.post.likeCount ?? 0, repostCount: item.post.repostCount ?? 0, replyCount: item.post.replyCount ?? 0, @@ -323,10 +311,10 @@ export function usePostFeedQuery( ) } - return { + const feedPostSlice: FeedPostSlice = { _reactKey: slice._reactKey, _isFeedPostSlice: true, - rootUri: slice.rootItem.post.uri, + rootUri: slice.uri, isThread: slice.items.length > 1 && slice.items.every( @@ -341,35 +329,42 @@ export function usePostFeedQuery( AppBskyFeedPost.validateRecord(item.post.record) .success ) { - const parentAuthor = - item.reply?.parent?.author ?? - slice.items[i + 1]?.reply?.grandparentAuthor + const parent = item.reply?.parent + let parentAuthor: + | AppBskyActorDefs.ProfileViewBasic + | undefined + if (AppBskyFeedDefs.isPostView(parent)) { + parentAuthor = parent.author + } + if (!parentAuthor) { + parentAuthor = + slice.items[i + 1]?.reply?.grandparentAuthor + } const replyRef = item.reply const isParentBlocked = AppBskyFeedDefs.isBlockedPost( replyRef?.parent, ) - return { + const feedPostSliceItem: FeedPostSliceItem = { _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`, uri: item.post.uri, post: item.post, record: item.post.record, - reason: - i === 0 && slice.source - ? slice.source - : item.reason, - feedContext: item.feedContext || slice.feedContext, + reason: slice.reason, + feedContext: slice.feedContext, moderation: moderations[i], parentAuthor, isParentBlocked, } + return feedPostSliceItem } return undefined }) - .filter(Boolean) as FeedPostSliceItem[], + .filter(n => !!n), } + return feedPostSlice }) - .filter(Boolean) as FeedPostSlice[], + .filter(n => !!n), })), ], } diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts index 67c6f8c084..8b6aeba6c1 100644 --- a/src/state/queries/threadgate.ts +++ b/src/state/queries/threadgate.ts @@ -4,7 +4,7 @@ export type ThreadgateSetting = | {type: 'nobody'} | {type: 'mention'} | {type: 'following'} - | {type: 'list'; list: string} + | {type: 'list'; list: unknown} export function threadgateViewToSettings( threadgate: AppBskyFeedDefs.ThreadgateView | undefined, @@ -21,18 +21,18 @@ export function threadgateViewToSettings( if (!record.allow?.length) { return [{type: 'nobody'}] } - return record.allow + const settings: ThreadgateSetting[] = record.allow .map(allow => { + let setting: ThreadgateSetting | undefined if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') { - return {type: 'mention'} + setting = {type: 'mention'} + } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') { + setting = {type: 'following'} + } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') { + setting = {type: 'list', list: allow.list} } - if (allow.$type === 'app.bsky.feed.threadgate#followingRule') { - return {type: 'following'} - } - if (allow.$type === 'app.bsky.feed.threadgate#listRule') { - return {type: 'list', list: allow.list} - } - return undefined + return setting }) - .filter(Boolean) as ThreadgateSetting[] + .filter(n => !!n) + return settings } diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts new file mode 100644 index 0000000000..a2c739cfde --- /dev/null +++ b/src/state/queries/video/compress-video.ts @@ -0,0 +1,31 @@ +import {ImagePickerAsset} from 'expo-image-picker' +import {useMutation} from '@tanstack/react-query' + +import {CompressedVideo, compressVideo} from 'lib/media/video/compress' + +export function useCompressVideoMutation({ + onProgress, + onSuccess, + onError, +}: { + onProgress: (progress: number) => void + onError: (e: any) => void + onSuccess: (video: CompressedVideo) => void +}) { + return useMutation({ + mutationFn: async (asset: ImagePickerAsset) => { + return await compressVideo(asset.uri, { + onProgress: num => onProgress(trunc2dp(num)), + }) + }, + onError, + onSuccess, + onMutate: () => { + onProgress(0) + }, + }) +} + +function trunc2dp(num: number) { + return Math.trunc(num * 100) / 100 +} diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts new file mode 100644 index 0000000000..266d8aee37 --- /dev/null +++ b/src/state/queries/video/util.ts @@ -0,0 +1,15 @@ +const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? '' + +export const createVideoEndpointUrl = ( + route: string, + params?: Record, +) => { + const url = new URL(`${UPLOAD_ENDPOINT}`) + url.pathname = route + if (params) { + for (const key in params) { + url.searchParams.set(key, params[key]) + } + } + return url.href +} diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts new file mode 100644 index 0000000000..4d7f7995c5 --- /dev/null +++ b/src/state/queries/video/video-upload.ts @@ -0,0 +1,59 @@ +import {createUploadTask, FileSystemUploadType} from 'expo-file-system' +import {useMutation} from '@tanstack/react-query' +import {nanoid} from 'nanoid/non-secure' + +import {CompressedVideo} from 'lib/media/video/compress' +import {UploadVideoResponse} from 'lib/media/video/types' +import {createVideoEndpointUrl} from 'state/queries/video/util' +import {useSession} from 'state/session' +const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' + +export const useUploadVideoMutation = ({ + onSuccess, + onError, + setProgress, +}: { + onSuccess: (response: UploadVideoResponse) => void + onError: (e: any) => void + setProgress: (progress: number) => void +}) => { + const {currentAccount} = useSession() + + return useMutation({ + mutationFn: async (video: CompressedVideo) => { + const uri = createVideoEndpointUrl('/upload', { + did: currentAccount!.did, + name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? + }) + + const uploadTask = createUploadTask( + uri, + video.uri, + { + headers: { + 'dev-key': UPLOAD_HEADER, + 'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4? + }, + httpMethod: 'POST', + uploadType: FileSystemUploadType.BINARY_CONTENT, + }, + p => { + setProgress(p.totalBytesSent / p.totalBytesExpectedToSend) + }, + ) + const res = await uploadTask.uploadAsync() + + if (!res?.body) { + throw new Error('No response') + } + + // @TODO rm, useful for debugging/getting video cid + console.log('[VIDEO]', res.body) + const responseBody = JSON.parse(res.body) as UploadVideoResponse + onSuccess(responseBody) + return responseBody + }, + onError, + onSuccess, + }) +} diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts new file mode 100644 index 0000000000..b5b9e93bf9 --- /dev/null +++ b/src/state/queries/video/video-upload.web.ts @@ -0,0 +1,66 @@ +import {useMutation} from '@tanstack/react-query' +import {nanoid} from 'nanoid/non-secure' + +import {CompressedVideo} from 'lib/media/video/compress' +import {UploadVideoResponse} from 'lib/media/video/types' +import {createVideoEndpointUrl} from 'state/queries/video/util' +import {useSession} from 'state/session' +const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' + +export const useUploadVideoMutation = ({ + onSuccess, + onError, + setProgress, +}: { + onSuccess: (response: UploadVideoResponse) => void + onError: (e: any) => void + setProgress: (progress: number) => void +}) => { + const {currentAccount} = useSession() + + return useMutation({ + mutationFn: async (video: CompressedVideo) => { + const uri = createVideoEndpointUrl('/upload', { + did: currentAccount!.did, + name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? + }) + + const bytes = await fetch(video.uri).then(res => res.arrayBuffer()) + + const xhr = new XMLHttpRequest() + const res = (await new Promise((resolve, reject) => { + xhr.upload.addEventListener('progress', e => { + const progress = e.loaded / e.total + setProgress(progress) + }) + xhr.onloadend = () => { + if (xhr.readyState === 4) { + const uploadRes = JSON.parse( + xhr.responseText, + ) as UploadVideoResponse + resolve(uploadRes) + onSuccess(uploadRes) + } else { + reject() + onError(new Error('Failed to upload video')) + } + } + xhr.onerror = () => { + reject() + onError(new Error('Failed to upload video')) + } + xhr.open('POST', uri) + xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type? + // @TODO remove this header for prod + xhr.setRequestHeader('dev-key', UPLOAD_HEADER) + xhr.send(bytes) + })) as UploadVideoResponse + + // @TODO rm for prod + console.log('[VIDEO]', res) + return res + }, + onError, + onSuccess, + }) +} diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts new file mode 100644 index 0000000000..295db38b43 --- /dev/null +++ b/src/state/queries/video/video.ts @@ -0,0 +1,212 @@ +import React from 'react' +import {ImagePickerAsset} from 'expo-image-picker' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQuery} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {CompressedVideo} from 'lib/media/video/compress' +import {VideoTooLargeError} from 'lib/media/video/errors' +import {JobState, JobStatus} from 'lib/media/video/types' +import {useCompressVideoMutation} from 'state/queries/video/compress-video' +import {createVideoEndpointUrl} from 'state/queries/video/util' +import {useUploadVideoMutation} from 'state/queries/video/video-upload' + +type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done' + +type Action = + | { + type: 'SetStatus' + status: Status + } + | { + type: 'SetProgress' + progress: number + } + | { + type: 'SetError' + error: string | undefined + } + | {type: 'Reset'} + | {type: 'SetAsset'; asset: ImagePickerAsset} + | {type: 'SetVideo'; video: CompressedVideo} + | {type: 'SetJobStatus'; jobStatus: JobStatus} + +export interface State { + status: Status + progress: number + asset?: ImagePickerAsset + video: CompressedVideo | null + jobStatus?: JobStatus + error?: string +} + +function reducer(state: State, action: Action): State { + let updatedState = state + if (action.type === 'SetStatus') { + updatedState = {...state, status: action.status} + } else if (action.type === 'SetProgress') { + updatedState = {...state, progress: action.progress} + } else if (action.type === 'SetError') { + updatedState = {...state, error: action.error} + } else if (action.type === 'Reset') { + updatedState = { + status: 'idle', + progress: 0, + video: null, + } + } else if (action.type === 'SetAsset') { + updatedState = {...state, asset: action.asset} + } else if (action.type === 'SetVideo') { + updatedState = {...state, video: action.video} + } else if (action.type === 'SetJobStatus') { + updatedState = {...state, jobStatus: action.jobStatus} + } + return updatedState +} + +export function useUploadVideo({ + setStatus, + onSuccess, +}: { + setStatus: (status: string) => void + onSuccess: () => void +}) { + const {_} = useLingui() + const [state, dispatch] = React.useReducer(reducer, { + status: 'idle', + progress: 0, + video: null, + }) + + const {setJobId} = useUploadStatusQuery({ + onStatusChange: (status: JobStatus) => { + // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user + // Leaving it for now though + dispatch({ + type: 'SetJobStatus', + jobStatus: status, + }) + setStatus(status.state.toString()) + }, + onSuccess: () => { + dispatch({ + type: 'SetStatus', + status: 'idle', + }) + onSuccess() + }, + }) + + const {mutate: onVideoCompressed} = useUploadVideoMutation({ + onSuccess: response => { + dispatch({ + type: 'SetStatus', + status: 'processing', + }) + setJobId(response.job_id) + }, + onError: e => { + dispatch({ + type: 'SetError', + error: _(msg`An error occurred while uploading the video.`), + }) + logger.error('Error uploading video', {safeMessage: e}) + }, + setProgress: p => { + dispatch({type: 'SetProgress', progress: p}) + }, + }) + + const {mutate: onSelectVideo} = useCompressVideoMutation({ + onProgress: p => { + dispatch({type: 'SetProgress', progress: p}) + }, + onError: e => { + if (e instanceof VideoTooLargeError) { + dispatch({ + type: 'SetError', + error: _(msg`The selected video is larger than 100MB.`), + }) + } else { + dispatch({ + type: 'SetError', + // @TODO better error message from server, left untranslated on purpose + error: 'An error occurred while compressing the video.', + }) + logger.error('Error compressing video', {safeMessage: e}) + } + }, + onSuccess: (video: CompressedVideo) => { + dispatch({ + type: 'SetVideo', + video, + }) + dispatch({ + type: 'SetStatus', + status: 'uploading', + }) + onVideoCompressed(video) + }, + }) + + const selectVideo = (asset: ImagePickerAsset) => { + dispatch({ + type: 'SetAsset', + asset, + }) + dispatch({ + type: 'SetStatus', + status: 'compressing', + }) + onSelectVideo(asset) + } + + const clearVideo = () => { + // @TODO cancel any running jobs + dispatch({type: 'Reset'}) + } + + return { + state, + dispatch, + selectVideo, + clearVideo, + } +} + +const useUploadStatusQuery = ({ + onStatusChange, + onSuccess, +}: { + onStatusChange: (status: JobStatus) => void + onSuccess: () => void +}) => { + const [enabled, setEnabled] = React.useState(true) + const [jobId, setJobId] = React.useState() + + const {isLoading, isError} = useQuery({ + queryKey: ['video-upload'], + queryFn: async () => { + const url = createVideoEndpointUrl(`/job/${jobId}/status`) + const res = await fetch(url) + const status = (await res.json()) as JobStatus + if (status.state === JobState.JOB_STATE_COMPLETED) { + setEnabled(false) + onSuccess() + } + onStatusChange(status) + return status + }, + enabled: Boolean(jobId && enabled), + refetchInterval: 1500, + }) + + return { + isLoading, + isError, + setJobId: (_jobId: string) => { + setJobId(_jobId) + }, + } +} diff --git a/src/state/shell/post-progress.tsx b/src/state/shell/post-progress.tsx new file mode 100644 index 0000000000..0df2a6be4a --- /dev/null +++ b/src/state/shell/post-progress.tsx @@ -0,0 +1,18 @@ +import React from 'react' + +interface PostProgressState { + progress: number + status: 'pending' | 'success' | 'error' | 'idle' + error?: string +} + +const PostProgressContext = React.createContext({ + progress: 0, + status: 'idle', +}) + +export function Provider() {} + +export function usePostProgress() { + return React.useContext(PostProgressContext) +} diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index 6df4e439aa..9ffcbfb9df 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -132,6 +132,7 @@ export const SplashScreen = ({ function Footer() { const t = useTheme() + const {_} = useLingui() return ( - + Business - + Blog - + Jobs diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index eed5cb6c54..08ce4441f0 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -13,10 +13,16 @@ import { Keyboard, KeyboardAvoidingView, LayoutChangeEvent, + StyleProp, StyleSheet, View, + ViewStyle, } from 'react-native' +// @ts-expect-error no type definition +import ProgressCircle from 'react-native-progress/Circle' import Animated, { + FadeIn, + FadeOut, interpolateColor, useAnimatedStyle, useSharedValue, @@ -55,6 +61,7 @@ import { import {useProfileQuery} from '#/state/queries/profile' import {Gif} from '#/state/queries/tenor' import {ThreadgateSetting} from '#/state/queries/threadgate' +import {useUploadVideo} from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {useAnalytics} from 'lib/analytics/analytics' @@ -70,6 +77,7 @@ import {colors, s} from 'lib/styles' import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection' import {useDialogStateControlContext} from 'state/dialogs' import {GalleryModel} from 'state/models/media/gallery' +import {State as VideoUploadState} from 'state/queries/video/video' import {ComposerOpts} from 'state/shell/composer' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' import {atoms as a, useTheme} from '#/alf' @@ -96,10 +104,8 @@ import {TextInput, TextInputRef} from './text-input/TextInput' import {ThreadgateBtn} from './threadgate/ThreadgateBtn' import {useExternalLinkFetch} from './useExternalLinkFetch' import {SelectVideoBtn} from './videos/SelectVideoBtn' -import {useVideoState} from './videos/state' import {VideoPreview} from './videos/VideoPreview' import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress' -import hairlineWidth = StyleSheet.hairlineWidth type CancelRef = { onPressCancel: () => void @@ -160,14 +166,21 @@ export const ComposePost = observer(function ComposePost({ const [quote, setQuote] = useState( initQuote, ) + const { - video, - onSelectVideo, - videoPending, - videoProcessingData, + selectVideo, clearVideo, - videoProcessingProgress, - } = useVideoState({setError}) + state: videoUploadState, + } = useUploadVideo({ + setStatus: (status: string) => setProcessingState(status), + onSuccess: () => { + if (publishOnUpload) { + onPressPublish(true) + } + }, + }) + const [publishOnUpload, setPublishOnUpload] = useState(false) + const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const [extGif, setExtGif] = useState() const [labels, setLabels] = useState([]) @@ -275,7 +288,7 @@ export const ComposePost = observer(function ComposePost({ return false }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled]) - const onPressPublish = async () => { + const onPressPublish = async (finishedUploading?: boolean) => { if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } @@ -284,6 +297,15 @@ export const ComposePost = observer(function ComposePost({ return } + if ( + !finishedUploading && + videoUploadState.status !== 'idle' && + videoUploadState.asset + ) { + setPublishOnUpload(true) + return + } + setError('') if ( @@ -388,8 +410,12 @@ export const ComposePost = observer(function ComposePost({ : _(msg`What's up?`) const canSelectImages = - gallery.size < 4 && !extLink && !video && !videoPending - const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video) + gallery.size < 4 && + !extLink && + videoUploadState.status === 'idle' && + !videoUploadState.video + const hasMedia = + gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video) const onEmojiButtonPress = useCallback(() => { openPicker?.(textInput.current?.getCursorPosition()) @@ -501,7 +527,10 @@ export const ComposePost = observer(function ComposePost({ shape="default" size="small" style={[a.rounded_full, a.py_sm]} - onPress={onPressPublish}> + onPress={() => onPressPublish()} + disabled={ + videoUploadState.status !== 'idle' && publishOnUpload + }> {replyTo ? ( Reply @@ -573,7 +602,7 @@ export const ComposePost = observer(function ComposePost({ autoFocus setRichText={setRichText} onPhotoPasted={onPhotoPasted} - onPressPublish={onPressPublish} + onPressPublish={() => onPressPublish()} onNewLink={onNewLink} onError={setError} accessible={true} @@ -603,29 +632,33 @@ export const ComposePost = observer(function ComposePost({ )} - {quote ? ( - - - + + {quote ? ( + + + + + {quote.uri !== initQuote?.uri && ( + setQuote(undefined)} /> + )} - {quote.uri !== initQuote?.uri && ( - setQuote(undefined)} /> - )} - - ) : null} - {videoPending && videoProcessingData ? ( - - ) : ( - video && ( + ) : null} + {videoUploadState.status === 'compressing' && + videoUploadState.asset ? ( + + ) : videoUploadState.video ? ( // remove suspense when we get rid of lazy - + - ) - )} + ) : null} + @@ -642,33 +675,37 @@ export const ComposePost = observer(function ComposePost({ t.atoms.border_contrast_medium, styles.bottomBar, ]}> - - - {gate('videos') && ( - + ) : ( + + + {gate('videos') && ( + + )} + + - )} - - - {!isMobile ? ( - - ) : null} - + {!isMobile ? ( + + ) : null} + + )} @@ -763,7 +800,7 @@ function useAnimatedBorders() { const topBarAnimatedStyle = useAnimatedStyle(() => { return { - borderBottomWidth: hairlineWidth, + borderBottomWidth: StyleSheet.hairlineWidth, borderColor: interpolateColor( hasScrolledTop.value, [0, 1], @@ -773,7 +810,7 @@ function useAnimatedBorders() { }) const bottomBarAnimatedStyle = useAnimatedStyle(() => { return { - borderTopWidth: hairlineWidth, + borderTopWidth: StyleSheet.hairlineWidth, borderColor: interpolateColor( hasScrolledBottom.value, [0, 1], @@ -855,7 +892,7 @@ const styles = StyleSheet.create({ marginBottom: 8, }, errorIcon: { - borderWidth: hairlineWidth, + borderWidth: StyleSheet.hairlineWidth, borderColor: colors.red4, color: colors.red4, borderRadius: 30, @@ -891,6 +928,47 @@ const styles = StyleSheet.create({ paddingLeft: 7, paddingRight: 16, alignItems: 'center', - borderTopWidth: hairlineWidth, + borderTopWidth: StyleSheet.hairlineWidth, }, }) + +function ToolbarWrapper({ + style, + children, +}: { + style: StyleProp + children: React.ReactNode +}) { + if (isWeb) return children + return ( + + {children} + + ) +} + +function VideoUploadToolbar({state}: {state: VideoUploadState}) { + const t = useTheme() + + const progress = + state.status === 'compressing' || state.status === 'uploading' + ? state.progress + : state.jobStatus?.progress ?? 100 + + return ( + + + {state.status} + + ) +} diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index 54defb101c..20637c7e9f 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -9,7 +9,6 @@ import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -import hairlineWidth = StyleSheet.hairlineWidth export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { const {currentAccount} = useSession() @@ -49,7 +48,7 @@ const styles = StyleSheet.create({ paddingBottom: 10, flexDirection: 'row', alignItems: 'center', - borderTopWidth: hairlineWidth, + borderTopWidth: StyleSheet.hairlineWidth, }, labelMobile: { paddingLeft: 12, diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index b04cdf1c8b..8e2a22852d 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -17,6 +17,7 @@ export function VideoPreview({ const player = useVideoPlayer(video.uri, player => { player.loop = true player.play() + player.volume = 0 }) return ( diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx index 79407cd3ef..db58448a30 100644 --- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx +++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx @@ -9,15 +9,15 @@ import {Text} from '#/components/Typography' import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' export function VideoTranscodeProgress({ - input, + asset, progress, }: { - input: ImagePickerAsset + asset: ImagePickerAsset progress: number }) { const t = useTheme() - const aspectRatio = input.width / input.height + const aspectRatio = asset.width / asset.height return ( - + void}) { - const {_} = useLingui() - const [progress, setProgress] = useState(0) - - const {mutate, data, isPending, isError, reset, variables} = useMutation({ - mutationFn: async (asset: ImagePickerAsset) => { - const compressed = await compressVideo(asset.uri, { - onProgress: num => setProgress(trunc2dp(num)), - }) - - return compressed - }, - onError: (e: any) => { - // Don't log these errors in sentry, just let the user know - if (e instanceof VideoTooLargeError) { - Toast.show(_(msg`Videos cannot be larger than 100MB`), 'xmark') - return - } - logger.error('Failed to compress video', {safeError: e}) - setError(_(msg`Could not compress video`)) - }, - onMutate: () => { - setProgress(0) - }, - }) - - return { - video: data, - onSelectVideo: mutate, - videoPending: isPending, - videoProcessingData: variables, - videoError: isError, - clearVideo: reset, - videoProcessingProgress: progress, - } -} - -function trunc2dp(num: number) { - return Math.trunc(num * 100) / 100 -} diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index c44353c8bf..68437c37a0 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -13,6 +13,7 @@ import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {logger} from '#/logger' +import {shouldClickOpenNewTab} from '#/platform/urls' import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' import { useAddSavedFeedsMutation, @@ -32,8 +33,6 @@ import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -import hairlineWidth = StyleSheet.hairlineWidth -import {shouldClickOpenNewTab} from '#/platform/urls' export function FeedSourceCard({ feedUri, @@ -209,7 +208,7 @@ export function FeedSourceCardLoaded({ styles.container, pal.border, style, - {borderTopWidth: hideTopBorder ? 0 : hairlineWidth}, + {borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth}, ]} onPress={e => { const shouldOpenInNewTab = shouldClickOpenNewTab(e) @@ -330,7 +329,7 @@ const styles = StyleSheet.create({ gap: 14, }, border: { - borderTopWidth: hairlineWidth, + borderTopWidth: StyleSheet.hairlineWidth, }, headerContainer: { flexDirection: 'row', diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index ed353cf168..e537abfaad 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -73,7 +73,7 @@ export function HomeHeaderLayoutMobile({ ]}> {IS_DEV && ( <> - + diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx index 3adf5c09da..587885502b 100644 --- a/src/view/com/lists/ListCard.tsx +++ b/src/view/com/lists/ListCard.tsx @@ -14,7 +14,6 @@ import {RichText as RichTextCom} from '#/components/RichText' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -import hairlineWidth = StyleSheet.hairlineWidth export const ListCard = ({ testID, @@ -134,7 +133,7 @@ export const ListCard = ({ const styles = StyleSheet.create({ outer: { - borderTopWidth: hairlineWidth, + borderTopWidth: StyleSheet.hairlineWidth, paddingHorizontal: 6, }, outerNoBorder: { diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx index 16c366d8af..f6db94ed85 100644 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ b/src/view/com/modals/UserAddRemoveLists.tsx @@ -29,7 +29,6 @@ import {Button} from '../util/forms/Button' import {Text} from '../util/text/Text' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' -import hairlineWidth = StyleSheet.hairlineWidth export const snapPoints = ['fullscreen'] @@ -63,7 +62,7 @@ export function Component({ return [pal.border, {height: screenHeight / 1.5}] } - return [pal.border, {flex: 1, borderTopWidth: hairlineWidth}] + return [pal.border, {flex: 1, borderTopWidth: StyleSheet.hairlineWidth}] }, [pal.border, screenHeight]) return ( @@ -188,7 +187,7 @@ function ListItem({ style={[ styles.listItem, pal.border, - index !== 0 && {borderTopWidth: hairlineWidth}, + index !== 0 && {borderTopWidth: StyleSheet.hairlineWidth}, ]}> @@ -248,7 +247,7 @@ const styles = StyleSheet.create({ gap: 10, paddingTop: 10, paddingBottom: isAndroid ? 10 : 0, - borderTopWidth: hairlineWidth, + borderTopWidth: StyleSheet.hairlineWidth, }, footerBtn: { paddingHorizontal: 24, diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index e2f12e84f1..82755de1d3 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -24,7 +24,6 @@ import {NotificationFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceho import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' import {CenteredView} from '#/view/com/util/Views' import {FeedItem} from './FeedItem' -import hairlineWidth = StyleSheet.hairlineWidth const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} @@ -35,11 +34,13 @@ export function Feed({ onPressTryAgain, onScrolledDownChange, ListHeaderComponent, + overridePriorityNotifications, }: { scrollElRef?: ListRef onPressTryAgain?: () => void onScrolledDownChange: (isScrolledDown: boolean) => void ListHeaderComponent?: () => JSX.Element + overridePriorityNotifications?: boolean }) { const initialNumToRender = useInitialNumToRender() @@ -59,7 +60,10 @@ export function Feed({ hasNextPage, isFetchingNextPage, fetchNextPage, - } = useNotificationFeedQuery({enabled: !!moderationOpts}) + } = useNotificationFeedQuery({ + enabled: !!moderationOpts, + overridePriorityNotifications, + }) const isEmpty = !isFetching && !data?.pages[0]?.items.length const items = React.useMemo(() => { @@ -132,7 +136,7 @@ export function Feed({ diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index d31962ff35..e4294eaa5f 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -13,27 +13,37 @@ import { AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, AppBskyFeedPost, + AppBskyGraphFollow, moderateProfile, ModerationDecision, ModerationOpts, } from '@atproto/api' import {AtUri} from '@atproto/api' +import {TID} from '@atproto/common-web' import {msg, plural, Trans} from '@lingui/macro' 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' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {usePalette} from 'lib/hooks/usePalette' import {makeProfileLink} from 'lib/routes/links' +import {NavigationProp} from 'lib/routes/types' +import {forceLTR} from 'lib/strings/bidi' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {niceDate} from 'lib/strings/time' import {colors, s} from 'lib/styles' import {isWeb} from 'platform/detection' +import {DM_SERVICE_HEADERS} from 'state/queries/messages/const' import {precacheProfile} from 'state/queries/profile' +import {useAgent} from 'state/session' import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, @@ -41,8 +51,10 @@ import { import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2' import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' +import {StarterPack} from '#/components/icons/StarterPack' import {Link as NewLink} from '#/components/Link' import {ProfileHoverCard} from '#/components/ProfileHoverCard' +import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {FeedSourceCard} from '../feeds/FeedSourceCard' import {Post} from '../post/Post' import {ImageHorzList} from '../util/images/ImageHorzList' @@ -52,19 +64,6 @@ import {Text} from '../util/text/Text' import {TimeElapsed} from '../util/TimeElapsed' import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' -import hairlineWidth = StyleSheet.hairlineWidth -import {useNavigation} from '@react-navigation/native' - -import {parseTenorGif} from '#/lib/strings/embed-player' -import {logger} from '#/logger' -import {NavigationProp} from 'lib/routes/types' -import {forceLTR} from 'lib/strings/bidi' -import {DM_SERVICE_HEADERS} from 'state/queries/messages/const' -import {useAgent} from 'state/session' -import {Button, ButtonText} from '#/components/Button' -import {StarterPack} from '#/components/icons/StarterPack' -import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' - const MAX_AUTHORS = 5 const EXPANDED_AUTHOR_EL_HEIGHT = 35 @@ -171,7 +170,6 @@ let FeedItem = ({ ) } - let isFollowBack = false let action = '' let icon = ( } else if (item.type === 'follow') { + let isFollowBack = false + if ( item.notification.author.viewer?.following && - gate('ungroup_follow_backs') + AppBskyGraphFollow.isRecord(item.notification.record) ) { - isFollowBack = true + let followingTimestamp + try { + const rkey = new AtUri(item.notification.author.viewer.following).rkey + followingTimestamp = TID.fromStr(rkey).timestamp() + } catch (e) { + // For some reason the following URI was invalid. Default to it not being a follow back. + console.error('Invalid following URI') + } + if (followingTimestamp) { + const followedTimestamp = + new Date(item.notification.record.createdAt).getTime() * 1000 + isFollowBack = followedTimestamp > followingTimestamp + } + } + + if (isFollowBack && gate('ungroup_follow_backs')) { action = _(msg`followed you back`) } else { action = _(msg`followed you`) @@ -211,7 +226,22 @@ let FeedItem = ({ return null } - let formattedCount = authors.length > 1 ? formatCount(authors.length - 1) : '' + const formattedCount = + authors.length > 1 ? formatCount(authors.length - 1) : '' + const firstAuthorName = sanitizeDisplayName( + authors[0].profile.displayName || authors[0].profile.handle, + ) + const niceTimestamp = niceDate(item.notification.indexedAt) + const a11yLabelUsers = + authors.length > 1 + ? _(msg` and `) + + plural(authors.length - 1, { + one: `${formattedCount} other`, + other: `${formattedCount} others`, + }) + : '' + const a11yLabel = `${firstAuthorName}${a11yLabelUsers} ${action} ${niceTimestamp}` + return ( 1 @@ -272,19 +304,18 @@ let FeedItem = ({ visible={!isAuthorsExpanded} authors={authors} onToggleAuthorsExpanded={onToggleAuthorsExpanded} - showDmButton={item.type === 'starterpack-joined' || isFollowBack} + showDmButton={item.type === 'starterpack-joined'} /> - + {authors.length > 1 ? ( @@ -306,7 +337,7 @@ let FeedItem = ({ {({timeElapsed}) => ( + title={niceTimestamp}> {' ' + timeElapsed} )} @@ -458,7 +489,6 @@ function CondensedAuthorsList({ profile={authors[0].profile} moderation={authors[0].moderation.ui('avatar')} type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'} - accessible={false} /> {showDmButton ? : null} @@ -476,7 +506,6 @@ function CondensedAuthorsList({ profile={author.profile} moderation={author.moderation.ui('avatar')} type={author.profile.associated?.labeler ? 'labeler' : 'user'} - accessible={false} /> ))} diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 1c83ecd6e2..4801ae5e32 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -7,7 +7,6 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {PressableWithHover} from '../util/PressableWithHover' import {Text} from '../util/text/Text' import {DraggableScrollView} from './DraggableScrollView' -import hairlineWidth = StyleSheet.hairlineWidth export interface TabBarProps { testID?: string @@ -208,6 +207,6 @@ const mobileStyles = StyleSheet.create({ left: 0, right: 0, top: '100%', - borderBottomWidth: hairlineWidth, + borderBottomWidth: StyleSheet.hairlineWidth, }, }) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 4e6ed27bed..63dac300a8 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -44,7 +44,6 @@ import {PostEmbeds} from '../util/post-embeds' import {PostMeta} from '../util/PostMeta' import {Text} from '../util/text/Text' import {PreviewableUserAvatar} from '../util/UserAvatar' -import hairlineWidth = StyleSheet.hairlineWidth export function PostThreadItem({ post, @@ -604,7 +603,7 @@ function PostOuterWrapper({ { flexDirection: 'row', paddingHorizontal: isMobile ? 10 : 6, - borderTopWidth: depth === 1 ? hairlineWidth : 0, + borderTopWidth: depth === 1 ? StyleSheet.hairlineWidth : 0, }, ]}> {Array.from(Array(depth - 1)).map((_, n: number) => ( @@ -699,7 +698,7 @@ function getThreadAuthor( const styles = StyleSheet.create({ outer: { - borderTopWidth: hairlineWidth, + borderTopWidth: StyleSheet.hairlineWidth, paddingLeft: 8, }, outerHighlighted: { @@ -709,7 +708,7 @@ const styles = StyleSheet.create({ paddingRight: 8, }, outerHighlightedRoot: { - borderTopWidth: hairlineWidth, + borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 16, }, noTopBorder: { @@ -761,8 +760,8 @@ const styles = StyleSheet.create({ expandedInfo: { flexDirection: 'row', padding: 10, - borderTopWidth: hairlineWidth, - borderBottomWidth: hairlineWidth, + borderTopWidth: StyleSheet.hairlineWidth, + borderBottomWidth: StyleSheet.hairlineWidth, marginTop: 5, marginBottom: 10, }, diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index a05339d4dc..ade0e7f10f 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -37,7 +37,6 @@ import {PostMeta} from '../util/PostMeta' import {Text} from '../util/text/Text' import {PreviewableUserAvatar} from '../util/UserAvatar' import {UserInfoText} from '../util/UserInfoText' -import hairlineWidth = StyleSheet.hairlineWidth export function Post({ post, @@ -155,7 +154,7 @@ function PostInner({ style={[ styles.outer, pal.border, - !hideTopBorder && {borderTopWidth: hairlineWidth}, + !hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth}, style, ]} onBeforePress={onBeforePress}> diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index a59eeea52e..9ddc54a989 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -16,8 +16,10 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useGate} from '#/lib/statsig/statsig' import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' import {useFeedFeedbackContext} from '#/state/feed-feedback' +import {useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types' import {MAX_POST_LINES} from 'lib/constants' @@ -29,6 +31,7 @@ import {countLines} from 'lib/strings/helpers' import {s} from 'lib/styles' import {precacheProfile} from 'state/queries/profile' import {atoms as a} from '#/alf' +import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' import {ContentHider} from '#/components/moderation/ContentHider' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichText} from '#/components/RichText' @@ -38,17 +41,19 @@ import {FeedNameText} from '../util/FeedInfoText' import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link' import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostEmbeds} from '../util/post-embeds' +import {VideoEmbed} from '../util/post-embeds/VideoEmbed' import {PostMeta} from '../util/PostMeta' import {Text} from '../util/text/Text' import {PreviewableUserAvatar} from '../util/UserAvatar' import {AviFollowButton} from './AviFollowButton' -import hairlineWidth = StyleSheet.hairlineWidth -import {useSession} from '#/state/session' -import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' interface FeedItemProps { record: AppBskyFeedPost.Record - reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined + reason: + | AppBskyFeedDefs.ReasonRepost + | ReasonFeedSource + | {[k: string]: unknown; $type: string} + | undefined moderation: ModerationDecision parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined showReplyTo: boolean @@ -132,6 +137,8 @@ let FeedItemInner = ({ const {openComposer} = useComposerControls() const pal = usePalette('default') const {_} = useLingui() + const gate = useGate() + const href = useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey) @@ -197,7 +204,8 @@ let FeedItemInner = ({ isThreadLastChild || (!isThreadChild && !isThreadParent) ? 8 : undefined, - borderTopWidth: hideTopBorder || isThreadChild ? 0 : hairlineWidth, + borderTopWidth: + hideTopBorder || isThreadChild ? 0 : StyleSheet.hairlineWidth, }, ] @@ -337,9 +345,11 @@ let FeedItemInner = ({ postHref={href} onOpenAuthor={onOpenAuthor} /> - {!isThreadChild && showReplyTo && parentAuthor && ( - - )} + {!isThreadChild && + showReplyTo && + (parentAuthor || isParentBlocked) && ( + + )} + {__DEV__ && gate('videos') && ( + + )} Reply to a blocked post + } else if (profile != null) { + const isMe = profile.did === currentAccount?.did + if (isMe) { + label = Reply to you + } else { + label = ( + + Reply to{' '} + + + + + ) + } + } + + if (!label) { + // Should not happen. + return null + } return ( @@ -450,29 +497,7 @@ function ReplyToLabel({ style={[pal.textLight, s.mr2]} lineHeight={1.2} numberOfLines={1}> - {isMe ? ( - Reply to you - ) : blocked ? ( - Reply to a blocked post - ) : ( - - Reply to{' '} - - - - - )} + {label} ) diff --git a/src/view/com/posts/FeedShutdownMsg.tsx b/src/view/com/posts/FeedShutdownMsg.tsx index 36b1706cb1..f12ecf12b6 100644 --- a/src/view/com/posts/FeedShutdownMsg.tsx +++ b/src/view/com/posts/FeedShutdownMsg.tsx @@ -99,6 +99,7 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) { This feed is no longer online. We are showing{' '} Discover diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 9458fbaf8e..fd32e37a42 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -23,12 +23,11 @@ import { KnownFollowers, shouldShowKnownFollowers, } from '#/components/KnownFollowers' +import * as Pills from '#/components/Pills' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {PreviewableUserAvatar} from '../util/UserAvatar' import {FollowButton} from './FollowButton' -import hairlineWidth = StyleSheet.hairlineWidth -import * as Pills from '#/components/Pills' export function ProfileCard({ testID, @@ -217,7 +216,7 @@ export function ProfileCardWithFollowBtn({ const styles = StyleSheet.create({ outer: { - borderTopWidth: hairlineWidth, + borderTopWidth: StyleSheet.hairlineWidth, paddingHorizontal: 6, paddingVertical: 4, }, diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index ac5febcda1..e07acef281 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -15,15 +15,13 @@ import {makeProfileLink} from 'lib/routes/links' import {NavigationProp} from 'lib/routes/types' import {sanitizeHandle} from 'lib/strings/handles' import {isNative} from 'platform/detection' +import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' +import {StarterPack} from '#/components/icons/StarterPack' import {TextLink} from '../util/Link' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {Text} from '../util/text/Text' import {UserAvatar, UserAvatarType} from '../util/UserAvatar' import {CenteredView} from '../util/Views' -import hairlineWidth = StyleSheet.hairlineWidth - -import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' -import {StarterPack} from '#/components/icons/StarterPack' export function ProfileSubpageHeader({ isLoading, @@ -84,7 +82,7 @@ export function ProfileSubpageHeader({ { flexDirection: 'row', alignItems: 'center', - borderBottomWidth: hairlineWidth, + borderBottomWidth: StyleSheet.hairlineWidth, paddingTop: isNative ? 0 : 8, paddingBottom: 8, paddingHorizontal: isMobile ? 12 : 14, diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 12d223db03..5aa699356d 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -344,10 +344,11 @@ function ListImpl( style={[styles.aboveTheFoldDetector, {height: headerOffset}]} /> {onStartReached && !isEmpty && ( - )} {headerComponent} @@ -368,11 +369,11 @@ function ListImpl( ) })} {onEndReached && !isEmpty && ( - )} {footerComponent} @@ -381,6 +382,34 @@ function ListImpl( ) } +function EdgeVisibility({ + root, + topMargin, + bottomMargin, + containerRef, + onVisibleChange, +}: { + root?: React.RefObject | null + topMargin?: string + bottomMargin?: string + containerRef: React.RefObject + onVisibleChange: (isVisible: boolean) => void +}) { + const [containerHeight, setContainerHeight] = React.useState(0) + useResizeObserver(containerRef, (w, h) => { + setContainerHeight(h) + }) + return ( + + ) +} + function useResizeObserver( ref: React.RefObject, onResize: undefined | ((w: number, h: number) => void), diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index adeefd4ddb..6e75e88ca6 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -17,7 +17,6 @@ import { Heart2_Stroke2_Corner0_Rounded as HeartIconOutline, } from '#/components/icons/Heart2' import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' -import hairlineWidth = StyleSheet.hairlineWidth export function LoadingPlaceholder({ width, @@ -236,7 +235,7 @@ export function FeedLoadingPlaceholder({ { paddingHorizontal: 12, paddingVertical: 18, - borderTopWidth: showTopBorder ? hairlineWidth : 0, + borderTopWidth: showTopBorder ? StyleSheet.hairlineWidth : 0, }, pal.border, style, diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index d510eed87f..f7c6bc2c91 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -12,7 +12,7 @@ import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' import {IS_TEST} from '#/env' -const TIMEOUT = 3.7e3 +const TIMEOUT = 2e3 export function show( message: string, diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index b727234093..8862bd0e4a 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -55,7 +55,6 @@ interface PreviewableUserAvatarProps extends BaseUserAvatarProps { profile: AppBskyActorDefs.ProfileViewBasic disableHoverCard?: boolean onBeforePress?: () => void - accessible?: boolean } const BLUR_AMOUNT = isWeb ? 5 : 100 @@ -412,7 +411,6 @@ let PreviewableUserAvatar = ({ profile, disableHoverCard, onBeforePress, - accessible = true, ...rest }: PreviewableUserAvatarProps): React.ReactNode => { const {_} = useLingui() @@ -426,12 +424,8 @@ let PreviewableUserAvatar = ({ return ( @@ -243,7 +242,7 @@ const styles = StyleSheet.create({ marginRight: 'auto', }, border: { - borderBottomWidth: hairlineWidth, + borderBottomWidth: StyleSheet.hairlineWidth, }, titleContainer: { marginLeft: 'auto', diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index 21998bfbdd..ed18c981a6 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -26,7 +26,6 @@ import Animated from 'react-native-reanimated' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {addStyle} from 'lib/styles' -import hairlineWidth = StyleSheet.hairlineWidth interface AddedProps { desktopFixedHeight?: boolean | number @@ -50,8 +49,8 @@ export const CenteredView = React.forwardRef(function CenteredView( } if (sideBorders) { style = addStyle(style, { - borderLeftWidth: hairlineWidth, - borderRightWidth: hairlineWidth, + borderLeftWidth: StyleSheet.hairlineWidth, + borderRightWidth: StyleSheet.hairlineWidth, }) style = addStyle(style, pal.border) } @@ -163,8 +162,8 @@ export const ScrollView = React.forwardRef(function ScrollViewImpl( const styles = StyleSheet.create({ contentContainer: { - borderLeftWidth: hairlineWidth, - borderRightWidth: hairlineWidth, + borderLeftWidth: StyleSheet.hairlineWidth, + borderRightWidth: StyleSheet.hairlineWidth, // @ts-ignore web only minHeight: '100vh', }, diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index 9de03a4f33..2310b1f271 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -13,7 +13,6 @@ import {clamp} from '#/lib/numbers' import {colors} from '#/lib/styles' import {isWeb} from '#/platform/detection' import {useSession} from '#/state/session' -import hairlineWidth = StyleSheet.hairlineWidth const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) @@ -74,7 +73,7 @@ const styles = StyleSheet.create({ // @ts-ignore 'fixed' is web only -prf position: isWeb ? 'fixed' : 'absolute', left: 18, - borderWidth: hairlineWidth, + borderWidth: StyleSheet.hairlineWidth, width: 52, height: 52, borderRadius: 26, diff --git a/src/view/com/util/post-embeds/ActiveVideoContext.tsx b/src/view/com/util/post-embeds/ActiveVideoContext.tsx new file mode 100644 index 0000000000..6804436a7e --- /dev/null +++ b/src/view/com/util/post-embeds/ActiveVideoContext.tsx @@ -0,0 +1,48 @@ +import React, {useCallback, useId, useMemo, useState} from 'react' + +import {VideoPlayerProvider} from './VideoPlayerContext' + +const ActiveVideoContext = React.createContext<{ + activeViewId: string | null + setActiveView: (viewId: string, src: string) => void +} | null>(null) + +export function ActiveVideoProvider({children}: {children: React.ReactNode}) { + const [activeViewId, setActiveViewId] = useState(null) + const [source, setSource] = useState(null) + + const value = useMemo( + () => ({ + activeViewId, + setActiveView: (viewId: string, src: string) => { + setActiveViewId(viewId) + setSource(src) + }, + }), + [activeViewId], + ) + + return ( + + + {children} + + + ) +} + +export function useActiveVideoView() { + const context = React.useContext(ActiveVideoContext) + if (!context) { + throw new Error('useActiveVideo must be used within a ActiveVideoProvider') + } + const id = useId() + + return { + active: context.activeViewId === id, + setActive: useCallback( + (source: string) => context.setActiveView(id, source), + [context, id], + ), + } +} diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 4e2e19f587..20c05b692b 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -39,7 +39,6 @@ import {Link} from '../Link' import {PostMeta} from '../PostMeta' import {Text} from '../text/Text' import {PostEmbeds} from '.' -import hairlineWidth = StyleSheet.hairlineWidth export function MaybeQuoteEmbed({ embed, @@ -262,7 +261,7 @@ const styles = StyleSheet.create({ marginTop: 8, paddingVertical: 12, paddingHorizontal: 12, - borderWidth: hairlineWidth, + borderWidth: StyleSheet.hairlineWidth, }, errorContainer: { flexDirection: 'row', @@ -272,7 +271,7 @@ const styles = StyleSheet.create({ marginTop: 8, paddingVertical: 14, paddingHorizontal: 14, - borderWidth: hairlineWidth, + borderWidth: StyleSheet.hairlineWidth, }, alert: { marginBottom: 6, diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx new file mode 100644 index 0000000000..5e5293a553 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -0,0 +1,44 @@ +import React, {useCallback} from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +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 {useActiveVideoView} from './ActiveVideoContext' +import {VideoEmbedInner} from './VideoEmbedInner' + +export function VideoEmbed({source}: {source: string}) { + const t = useTheme() + const {active, setActive} = useActiveVideoView() + const {_} = useLingui() + + const onPress = useCallback(() => setActive(source), [setActive, source]) + + return ( + + {active ? ( + + ) : ( + + )} + + ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.tsx new file mode 100644 index 0000000000..ef06787097 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner.tsx @@ -0,0 +1,138 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native' +import Animated, { + measure, + runOnJS, + useAnimatedRef, + useFrameCallback, + useSharedValue, +} from 'react-native-reanimated' +import {VideoPlayer, VideoView} from 'expo-video' + +import {atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import {useVideoPlayer} from './VideoPlayerContext' + +export const VideoEmbedInner = ({}: {source: string}) => { + const player = useVideoPlayer() + const aref = useAnimatedRef() + const {height: windowHeight} = useWindowDimensions() + const hasLeftView = useSharedValue(false) + const ref = useRef(null) + + const onEnterView = useCallback(() => { + if (player.status === 'readyToPlay') { + player.play() + } + }, [player]) + + const onLeaveView = useCallback(() => { + player.pause() + }, [player]) + + const enterFullscreen = useCallback(() => { + if (ref.current) { + ref.current.enterFullscreen() + } + }, []) + + useFrameCallback(() => { + const measurement = measure(aref) + + if (measurement) { + if (hasLeftView.value) { + // Check if the video is in view + if ( + measurement.pageY >= 0 && + measurement.pageY + measurement.height <= windowHeight + ) { + runOnJS(onEnterView)() + hasLeftView.value = false + } + } else { + // Check if the video is out of view + if ( + measurement.pageY + measurement.height < 0 || + measurement.pageY > windowHeight + ) { + runOnJS(onLeaveView)() + hasLeftView.value = true + } + } + } + }) + + return ( + + + + + ) +} + +function VideoControls({ + player, + enterFullscreen, +}: { + player: VideoPlayer + enterFullscreen: () => void +}) { + const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime)) + + useEffect(() => { + const interval = setInterval(() => { + setCurrentTime(Math.floor(player.duration - player.currentTime)) + // how often should we update the time? + // 1000 gets out of sync with the video time + }, 250) + + return () => { + clearInterval(interval) + } + }, [player]) + + const minutes = Math.floor(currentTime / 60) + const seconds = String(currentTime % 60).padStart(2, '0') + + return ( + + + + {minutes}:{seconds} + + + + + ) +} + +const styles = StyleSheet.create({ + timeContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + position: 'absolute', + left: 5, + bottom: 5, + }, + timeElapsed: { + color: 'white', + fontSize: 12, + fontWeight: 'bold', + }, +}) diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx new file mode 100644 index 0000000000..cb02743c6f --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx @@ -0,0 +1,52 @@ +import React, {useEffect, useRef} from 'react' +import Hls from 'hls.js' + +import {atoms as a} from '#/alf' + +export const VideoEmbedInner = ({source}: {source: string}) => { + const ref = useRef(null) + + // Use HLS.js to play HLS video + useEffect(() => { + if (ref.current) { + if (ref.current.canPlayType('application/vnd.apple.mpegurl')) { + ref.current.src = source + } else if (Hls.isSupported()) { + var hls = new Hls() + hls.loadSource(source) + hls.attachMedia(ref.current) + } else { + // TODO: fallback + } + } + }, [source]) + + useEffect(() => { + if (ref.current) { + const observer = new IntersectionObserver( + ([entry]) => { + if (ref.current) { + if (entry.isIntersecting) { + if (ref.current.paused) { + ref.current.play() + } + } else { + if (!ref.current.paused) { + ref.current.pause() + } + } + } + }, + {threshold: 0}, + ) + + observer.observe(ref.current) + + return () => { + observer.disconnect() + } + } + }, []) + + return