[APP-1767] Live event feeds (#9696)
* Update env config for new worker setup * Add liveEvents context * Add live event feed card to Explore * Add Discover banner/trending handling, device storage * Add prefs methods, hook up to Discover banner * Add settings toggle * Fix up metrics and mutations * Simplify undo logic * Handle previews * Update overlay color and text color handling * Update metrics * Handle live event in feed card * Show multiple feeds on Explore for bsky team * Fix dev/preview handling bug * Add sidebar cards, update types * Fix type error * Fix type error * Feedback * Blurhash fix * Copy update * Dev only debug hook
This commit is contained in:
+50
-43
@@ -69,6 +69,10 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
|
||||
import {ToastOutlet} from '#/components/Toast'
|
||||
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
|
||||
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
|
||||
import {
|
||||
prefetchLiveEvents,
|
||||
Provider as LiveEventsProvider,
|
||||
} from '#/features/liveEvents/context'
|
||||
import * as Geo from '#/geolocation'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BottomSheetProvider} from '../modules/bottom-sheet'
|
||||
@@ -92,6 +96,7 @@ if (isAndroid) {
|
||||
*/
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -141,49 +146,51 @@ function InnerApp() {
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceAccountManager>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceAccountManager>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
<LiveEventsProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceAccountManager>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceAccountManager>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
</LiveEventsProvider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
|
||||
+46
-39
@@ -57,6 +57,10 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
|
||||
import {ToastOutlet} from '#/components/Toast'
|
||||
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
|
||||
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
|
||||
import {
|
||||
prefetchLiveEvents,
|
||||
Provider as LiveEventsProvider,
|
||||
} from '#/features/liveEvents/context'
|
||||
import * as Geo from '#/geolocation'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
@@ -67,6 +71,7 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
|
||||
*/
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -117,45 +122,47 @@ function InnerApp() {
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
<LiveEventsProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
</LiveEventsProvider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
|
||||
+6
-1
@@ -11,7 +11,12 @@ import {
|
||||
import {themes} from '#/alf/themes'
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
|
||||
export {
|
||||
type TextStyleProp,
|
||||
type Theme,
|
||||
utils,
|
||||
type ViewStyleProp,
|
||||
} from '@bsky.app/alf'
|
||||
export {atoms} from '#/alf/atoms'
|
||||
export * from '#/alf/breakpoints'
|
||||
export * from '#/alf/fonts'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, {useMemo} from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
@@ -21,19 +21,21 @@ import {
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, select, useTheme} from '#/alf'
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
type ButtonProps,
|
||||
ButtonText,
|
||||
} from '#/components/Button'
|
||||
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
|
||||
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
||||
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText, type RichTextProps} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useActiveLiveEventFeedUris} from '#/features/liveEvents/context'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
|
||||
|
||||
@@ -49,7 +51,11 @@ export function Default(props: Props) {
|
||||
<Outer>
|
||||
<Header>
|
||||
<Avatar src={view.avatar} />
|
||||
<TitleAndByline title={view.displayName} creator={view.creator} />
|
||||
<TitleAndByline
|
||||
title={view.displayName}
|
||||
creator={view.creator}
|
||||
uri={view.uri}
|
||||
/>
|
||||
<SaveButton view={view} pin />
|
||||
</Header>
|
||||
<Description description={view.description} />
|
||||
@@ -118,14 +124,40 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
|
||||
export function TitleAndByline({
|
||||
title,
|
||||
creator,
|
||||
uri,
|
||||
}: {
|
||||
title: string
|
||||
creator?: bsky.profile.AnyProfileView
|
||||
uri?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const activeLiveEvents = useActiveLiveEventFeedUris()
|
||||
const liveColor = useMemo(
|
||||
() =>
|
||||
select(t.name, {
|
||||
dark: t.palette.negative_600,
|
||||
dim: t.palette.negative_600,
|
||||
light: t.palette.negative_500,
|
||||
}),
|
||||
[t],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
{uri && activeLiveEvents.has(uri) && (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
|
||||
<LiveIcon size="xs" fill={liveColor} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_2xs,
|
||||
a.font_medium,
|
||||
a.leading_snug,
|
||||
{color: liveColor},
|
||||
]}>
|
||||
<Trans>Happening now</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
emoji
|
||||
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
|
||||
|
||||
@@ -842,6 +842,7 @@ export function SuggestedFeeds() {
|
||||
<FeedCard.TitleAndByline
|
||||
title={feed.displayName}
|
||||
creator={feed.creator}
|
||||
uri={feed.uri}
|
||||
/>
|
||||
</FeedCard.Header>
|
||||
<FeedCard.Description
|
||||
|
||||
@@ -17,16 +17,16 @@ export function FeedEmbed({
|
||||
return (
|
||||
<FeedCard.Link
|
||||
view={embed.view}
|
||||
style={[a.border, t.atoms.border_contrast_low, a.p_md, a.rounded_sm]}>
|
||||
style={[a.border, t.atoms.border_contrast_low, a.p_sm, a.rounded_md]}>
|
||||
<FeedCard.Outer>
|
||||
<FeedCard.Header>
|
||||
<FeedCard.Avatar src={embed.view.avatar} />
|
||||
<FeedCard.Avatar src={embed.view.avatar} size={48} />
|
||||
<FeedCard.TitleAndByline
|
||||
title={embed.view.displayName}
|
||||
creator={embed.view.creator}
|
||||
uri={embed.view.uri}
|
||||
/>
|
||||
</FeedCard.Header>
|
||||
<FeedCard.Likes count={embed.view.likeCount || 0} />
|
||||
</FeedCard.Outer>
|
||||
</FeedCard.Link>
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ export function Inner() {
|
||||
}, [setTrendingDisabled])
|
||||
|
||||
return error || noTopics ? null : (
|
||||
<View style={[t.atoms.border_contrast_low, a.border_t]}>
|
||||
<View style={[t.atoms.border_contrast_low, a.border_t, a.border_b]}>
|
||||
<BlockDrawerGesture>
|
||||
<ScrollView
|
||||
horizontal
|
||||
|
||||
Vendored
+15
-5
@@ -108,8 +108,18 @@ export const GCP_PROJECT_ID: number =
|
||||
* URLs for the app config web worker. Can be a
|
||||
* locally running server, see `env.example` for more.
|
||||
*/
|
||||
export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL
|
||||
export const BAPP_CONFIG_PROD_URL = `https://ip.bsky.app`
|
||||
export const BAPP_CONFIG_URL = IS_DEV
|
||||
? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_PROD_URL)
|
||||
: BAPP_CONFIG_PROD_URL
|
||||
export const GEOLOCATION_DEV_URL = process.env.GEOLOCATION_DEV_URL
|
||||
export const GEOLOCATION_PROD_URL = `https://ip.bsky.app`
|
||||
export const GEOLOCATION_URL = IS_DEV
|
||||
? (GEOLOCATION_DEV_URL ?? GEOLOCATION_PROD_URL)
|
||||
: GEOLOCATION_PROD_URL
|
||||
|
||||
/**
|
||||
* URLs for the live-event config web worker. Can be a
|
||||
* locally running server, see `env.example` for more.
|
||||
*/
|
||||
export const LIVE_EVENTS_DEV_URL = process.env.LIVE_EVENTS_DEV_URL
|
||||
export const LIVE_EVENTS_PROD_URL = `https://live-events.workers.bsky.app`
|
||||
export const LIVE_EVENTS_URL = IS_DEV
|
||||
? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL)
|
||||
: LIVE_EVENTS_PROD_URL
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useTrendingSettings} from '#/state/preferences/trending'
|
||||
import {atoms as a, useLayoutBreakpoints} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
|
||||
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
||||
import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide'
|
||||
import {
|
||||
LiveEventFeedOptionsMenu,
|
||||
useDialogControl,
|
||||
} from '#/features/liveEvents/components/LiveEventFeedOptionsMenu'
|
||||
import {useUserPreferencedLiveEvents} from '#/features/liveEvents/context'
|
||||
import {type LiveEventFeed} from '#/features/liveEvents/types'
|
||||
|
||||
export function DiscoverFeedLiveEventFeedsAndTrendingBanner() {
|
||||
const events = useUserPreferencedLiveEvents()
|
||||
const {rightNavVisible} = useLayoutBreakpoints()
|
||||
const {trendingDisabled} = useTrendingSettings()
|
||||
|
||||
if (!events.feeds.length) {
|
||||
if (!rightNavVisible && !trendingDisabled) {
|
||||
// only show trending on mobile when live event banner is not shown
|
||||
return <TrendingInterstitial />
|
||||
} else {
|
||||
// no feed, no trending
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// On desktop, we show in the sidebar
|
||||
if (rightNavVisible) return null
|
||||
|
||||
return events.feeds.map(feed => <Inner feed={feed} key={feed.id} />)
|
||||
}
|
||||
|
||||
function Inner({feed}: {feed: LiveEventFeed}) {
|
||||
const {_} = useLingui()
|
||||
const optionsMenuControl = useDialogControl()
|
||||
const layout = feed.layouts.wide
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[a.px_lg, a.pt_md, a.pb_xs]}>
|
||||
<View>
|
||||
<LiveEventFeedCardWide feed={feed} metricContext="discover" />
|
||||
|
||||
<Button
|
||||
label={_(msg`Configure live event banner`)}
|
||||
size="tiny"
|
||||
shape="round"
|
||||
style={[a.absolute, a.z_10, {top: 6, right: 6}]}
|
||||
onPress={() => {
|
||||
optionsMenuControl.open()
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.rounded_full,
|
||||
{
|
||||
backgroundColor: layout.overlayColor,
|
||||
opacity: hovered || pressed ? 0.8 : 0.6,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<EllipsisIcon
|
||||
size="sm"
|
||||
fill={layout.textColor}
|
||||
style={[a.z_20]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<LiveEventFeedOptionsMenu
|
||||
feed={feed}
|
||||
control={optionsMenuControl}
|
||||
metricContext="discover"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide'
|
||||
import {useLiveEvents} from '#/features/liveEvents/context'
|
||||
|
||||
export function ExploreScreenLiveEventFeedsBanner() {
|
||||
const t = useTheme()
|
||||
const events = useLiveEvents()
|
||||
return events.feeds.map(feed => (
|
||||
<View
|
||||
key={feed.id}
|
||||
style={[a.p_lg, a.border_b, t.atoms.border_contrast_low]}>
|
||||
<LiveEventFeedCardWide feed={feed} metricContext="explore" />
|
||||
</View>
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {useEffect, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {atoms as a, utils} from '#/alf'
|
||||
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
type LiveEventFeed,
|
||||
type LiveEventFeedMetricContext,
|
||||
} from '#/features/liveEvents/types'
|
||||
|
||||
const roundedStyles = [a.rounded_md, a.curve_continuous]
|
||||
|
||||
export function LiveEventFeedCardCompact({
|
||||
feed,
|
||||
metricContext,
|
||||
}: {
|
||||
feed: LiveEventFeed
|
||||
metricContext: LiveEventFeedMetricContext
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const layout = feed.layouts.compact
|
||||
const overlayColor = layout.overlayColor
|
||||
const textColor = layout.textColor
|
||||
const url = useMemo(() => {
|
||||
// Validated in multiple places on the backend
|
||||
if (isBskyCustomFeedUrl(feed.url)) {
|
||||
return new URL(feed.url).pathname
|
||||
}
|
||||
return '/'
|
||||
}, [feed.url])
|
||||
|
||||
useEffect(() => {
|
||||
logger.metric('liveEvents:feedBanner:seen', {
|
||||
feed: feed.url,
|
||||
context: metricContext,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={url}
|
||||
label={_(msg`Live event happening now: ${feed.title}`)}
|
||||
style={[a.w_full]}
|
||||
onPress={() => {
|
||||
logger.metric('liveEvents:feedBanner:click', {
|
||||
feed: feed.url,
|
||||
context: metricContext,
|
||||
})
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<View style={[roundedStyles, a.shadow_md, a.w_full]}>
|
||||
<View
|
||||
style={[a.w_full, a.align_start, a.overflow_hidden, roundedStyles]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={{uri: layout.image}}
|
||||
placeholder={{blurhash: layout.blurhash}}
|
||||
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
|
||||
contentFit="cover"
|
||||
placeholderContentFit="cover"
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
colors={[overlayColor, utils.alpha(overlayColor, 0)]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 0}}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.transition_opacity,
|
||||
{
|
||||
transitionDuration: '200ms',
|
||||
opacity: hovered || pressed ? 0.6 : 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View style={[a.w_full, a.justify_end]}>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
overlayColor,
|
||||
utils.alpha(overlayColor, 0.7),
|
||||
utils.alpha(overlayColor, 0),
|
||||
]}
|
||||
locations={[0, 0.8, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 0}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
a.z_10,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
]}>
|
||||
<LiveIcon size="md" fill={textColor} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.leading_snug,
|
||||
a.font_bold,
|
||||
a.text_lg,
|
||||
a.pr_xl,
|
||||
{color: textColor},
|
||||
]}>
|
||||
{layout.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {useEffect, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {atoms as a, useBreakpoints, utils} from '#/alf'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
type LiveEventFeed,
|
||||
type LiveEventFeedMetricContext,
|
||||
} from '#/features/liveEvents/types'
|
||||
|
||||
const roundedStyles = [a.rounded_lg, a.curve_continuous]
|
||||
|
||||
export function LiveEventFeedCardWide({
|
||||
feed,
|
||||
metricContext,
|
||||
}: {
|
||||
feed: LiveEventFeed
|
||||
metricContext: LiveEventFeedMetricContext
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
const layout = feed.layouts.wide
|
||||
const overlayColor = layout.overlayColor
|
||||
const textColor = layout.textColor
|
||||
const url = useMemo(() => {
|
||||
// Validated in multiple places on the backend
|
||||
if (isBskyCustomFeedUrl(feed.url)) {
|
||||
return new URL(feed.url).pathname
|
||||
}
|
||||
return '/'
|
||||
}, [feed.url])
|
||||
|
||||
useEffect(() => {
|
||||
logger.metric('liveEvents:feedBanner:seen', {
|
||||
feed: feed.url,
|
||||
context: metricContext,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={url}
|
||||
label={_(msg`Live event happening now: ${feed.title}`)}
|
||||
style={[a.w_full]}
|
||||
onPress={() => {
|
||||
logger.metric('liveEvents:feedBanner:click', {
|
||||
feed: feed.url,
|
||||
context: metricContext,
|
||||
})
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<View style={[roundedStyles, a.shadow_md, a.w_full]}>
|
||||
<View
|
||||
style={[
|
||||
a.align_start,
|
||||
roundedStyles,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
aspectRatio: gtPhone ? 576 / 144 : 369 / 100,
|
||||
},
|
||||
]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={{uri: layout.image}}
|
||||
placeholder={{blurhash: layout.blurhash}}
|
||||
style={[a.absolute, a.inset_0, a.w_full, a.h_full]}
|
||||
contentFit="cover"
|
||||
placeholderContentFit="cover"
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
colors={[overlayColor, utils.alpha(overlayColor, 0)]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 0}}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.transition_opacity,
|
||||
{
|
||||
transitionDuration: '200ms',
|
||||
opacity: hovered || pressed ? 0.6 : 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View style={[a.flex_1, a.justify_end]}>
|
||||
<LinearGradient
|
||||
colors={[overlayColor, utils.alpha(overlayColor, 0)]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 0}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.z_10,
|
||||
gtPhone ? [a.pl_xl, a.pb_lg] : [a.pl_lg, a.pb_md],
|
||||
{paddingRight: 64},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.leading_snug,
|
||||
gtPhone ? a.text_xs : a.text_2xs,
|
||||
{color: textColor, opacity: 0.8},
|
||||
]}>
|
||||
{feed.preview ? (
|
||||
<Trans>Preview</Trans>
|
||||
) : (
|
||||
<Trans>Happening now</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.leading_snug,
|
||||
a.font_bold,
|
||||
gtPhone ? a.text_3xl : a.text_lg,
|
||||
{color: textColor},
|
||||
]}>
|
||||
{layout.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as toast from '#/components/Toast'
|
||||
import {Span, Text} from '#/components/Typography'
|
||||
import {useUpdateLiveEventPreferences} from '#/features/liveEvents/preferences'
|
||||
import {
|
||||
type LiveEventFeed,
|
||||
type LiveEventFeedMetricContext,
|
||||
} from '#/features/liveEvents/types'
|
||||
|
||||
export {useDialogControl} from '#/components/Dialog'
|
||||
|
||||
export function LiveEventFeedOptionsMenu({
|
||||
control,
|
||||
feed,
|
||||
metricContext,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
feed: LiveEventFeed
|
||||
metricContext: LiveEventFeedMetricContext
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Configure live event banner`)}
|
||||
style={[web({maxWidth: 400})]}>
|
||||
<Inner control={control} feed={feed} metricContext={metricContext} />
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function Inner({
|
||||
control,
|
||||
feed,
|
||||
metricContext,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
feed: LiveEventFeed
|
||||
metricContext: LiveEventFeedMetricContext
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
isPending,
|
||||
mutate: update,
|
||||
error: rawError,
|
||||
variables,
|
||||
} = useUpdateLiveEventPreferences({
|
||||
feed,
|
||||
metricContext,
|
||||
onUpdateSuccess({undoAction}) {
|
||||
toast.show(
|
||||
<toast.Outer>
|
||||
<toast.Icon />
|
||||
<toast.Text>
|
||||
<Trans>Your live event preferences have been updated.</Trans>
|
||||
</toast.Text>
|
||||
{undoAction && (
|
||||
<toast.Action
|
||||
label={_(msg`Undo`)}
|
||||
onPress={() => {
|
||||
if (undoAction) {
|
||||
update(undoAction)
|
||||
}
|
||||
}}>
|
||||
<Trans>Undo</Trans>
|
||||
</toast.Action>
|
||||
)}
|
||||
</toast.Outer>,
|
||||
{
|
||||
type: 'success',
|
||||
},
|
||||
)
|
||||
|
||||
/*
|
||||
* If there is no `undoAction`, it means that the action was already
|
||||
* undone, and therefore the menu would have been closed prior to the
|
||||
* undo happening.
|
||||
*/
|
||||
if (undoAction) {
|
||||
control.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
const cleanError = useCleanError()
|
||||
const error = rawError ? cleanError(rawError) : undefined
|
||||
|
||||
const isHidingFeed = variables?.type === 'hideFeed' && isPending
|
||||
const isHidingAllFeeds = variables?.type === 'toggleHideAllFeeds' && isPending
|
||||
|
||||
return (
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.text_2xl, a.font_semi_bold, a.leading_snug]}>
|
||||
<Trans>Live event options</Trans>
|
||||
</Text>
|
||||
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Live events appear occasionally when something exciting is
|
||||
happening. If you'd like, you can hide this particular event, or all
|
||||
events for this placement in your app interface.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
If you choose to hide all events, you can always re-enable them from{' '}
|
||||
<Span style={[a.font_semi_bold]}>Settings → Content & Media</Span>.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_sm]}>
|
||||
<Button
|
||||
label={_(msg`Hide this event`)}
|
||||
size="large"
|
||||
color="primary_subtle"
|
||||
onPress={() => {
|
||||
update({type: 'hideFeed', id: feed.id})
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Hide this event</Trans>
|
||||
</ButtonText>
|
||||
{isHidingFeed && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Hide all events`)}
|
||||
size="large"
|
||||
color="secondary"
|
||||
onPress={() => {
|
||||
update({type: 'toggleHideAllFeeds'})
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Hide all events</Trans>
|
||||
</ButtonText>
|
||||
{isHidingAllFeeds && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
{isNative && (
|
||||
<Button
|
||||
label={_(msg`Cancel`)}
|
||||
size="large"
|
||||
color="secondary_inverted"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<Admonition type="error">
|
||||
{error.clean || error.raw || _(msg`An unknown error occurred.`)}
|
||||
</Admonition>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import * as SettingsList from '#/screens/Settings/components/SettingsList'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
|
||||
import {
|
||||
useLiveEventPreferences,
|
||||
useUpdateLiveEventPreferences,
|
||||
} from '#/features/liveEvents/preferences'
|
||||
|
||||
export function LiveEventFeedsSettingsToggle() {
|
||||
const {_} = useLingui()
|
||||
const {data: prefs} = useLiveEventPreferences()
|
||||
const {
|
||||
isPending,
|
||||
data: updatedPrefs,
|
||||
mutate: update,
|
||||
} = useUpdateLiveEventPreferences({
|
||||
metricContext: 'settings',
|
||||
})
|
||||
const hideAllFeeds = !!(updatedPrefs || prefs)?.hideAllFeeds
|
||||
|
||||
return (
|
||||
<Toggle.Item
|
||||
name="enable_live_event_banner"
|
||||
label={_(msg`Show live events in your Discover Feed`)}
|
||||
value={!hideAllFeeds}
|
||||
onChange={() => {
|
||||
if (!isPending) {
|
||||
update({type: 'toggleHideAllFeeds'})
|
||||
}
|
||||
}}>
|
||||
<SettingsList.Item>
|
||||
<SettingsList.ItemIcon icon={LiveIcon} />
|
||||
<SettingsList.ItemText>
|
||||
<Trans>Show live events in your Discover Feed</Trans>
|
||||
</SettingsList.ItemText>
|
||||
<Toggle.Platform />
|
||||
</SettingsList.Item>
|
||||
</Toggle.Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {LiveEventFeedCardCompact} from '#/features/liveEvents/components/LiveEventFeedCardCompact'
|
||||
import {useLiveEvents} from '#/features/liveEvents/context'
|
||||
|
||||
export function SidebarLiveEventFeedsBanner() {
|
||||
const events = useLiveEvents()
|
||||
return events.feeds.map(feed => (
|
||||
<LiveEventFeedCardCompact
|
||||
key={feed.id}
|
||||
feed={feed}
|
||||
metricContext="sidebar"
|
||||
/>
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {QueryClient, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {useIsBskyTeam} from '#/lib/hooks/useIsBskyTeam'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isBskyCustomFeedUrl,
|
||||
makeRecordUri,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {IS_DEV, LIVE_EVENTS_URL} from '#/env'
|
||||
import {useLiveEventPreferences} from '#/features/liveEvents/preferences'
|
||||
import {type LiveEventsWorkerResponse} from '#/features/liveEvents/types'
|
||||
import {useDevMode} from '#/storage/hooks/dev-mode'
|
||||
|
||||
const qc = new QueryClient()
|
||||
const liveEventsQueryKey = ['live-events']
|
||||
|
||||
export const DEFAULT_LIVE_EVENTS = {
|
||||
feeds: [],
|
||||
}
|
||||
|
||||
async function fetchLiveEvents(): Promise<LiveEventsWorkerResponse | null> {
|
||||
try {
|
||||
const res = await fetch(`${LIVE_EVENTS_URL}/config`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const Context = createContext<LiveEventsWorkerResponse>(DEFAULT_LIVE_EVENTS)
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [isDevMode] = useDevMode()
|
||||
const isBskyTeam = useIsBskyTeam()
|
||||
const {data} = useQuery(
|
||||
{
|
||||
staleTime: IS_DEV ? 5e3 : 1000 * 60,
|
||||
queryKey: liveEventsQueryKey,
|
||||
async queryFn() {
|
||||
return fetchLiveEvents()
|
||||
},
|
||||
},
|
||||
qc,
|
||||
)
|
||||
|
||||
const ctx = useMemo(() => {
|
||||
if (!data) return DEFAULT_LIVE_EVENTS
|
||||
const feeds = data.feeds.filter(f => {
|
||||
if (f.preview && !isBskyTeam) return false
|
||||
return true
|
||||
})
|
||||
return {
|
||||
...data,
|
||||
// only one at a time for now, unless bsky team and dev mode
|
||||
feeds: isBskyTeam && isDevMode ? feeds : feeds.slice(0, 1),
|
||||
}
|
||||
}, [data, isBskyTeam, isDevMode])
|
||||
|
||||
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export async function prefetchLiveEvents() {
|
||||
const data = await fetchLiveEvents()
|
||||
if (data) {
|
||||
qc.setQueryData(liveEventsQueryKey, data)
|
||||
}
|
||||
}
|
||||
|
||||
export function useLiveEvents() {
|
||||
const ctx = useContext(Context)
|
||||
if (!ctx) {
|
||||
throw new Error('useLiveEventsContext must be used within a Provider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useUserPreferencedLiveEvents() {
|
||||
const events = useLiveEvents()
|
||||
const {data, isLoading} = useLiveEventPreferences()
|
||||
if (isLoading) return DEFAULT_LIVE_EVENTS
|
||||
const {hideAllFeeds, hiddenFeedIds} = data
|
||||
return {
|
||||
...events,
|
||||
feeds: hideAllFeeds
|
||||
? []
|
||||
: events.feeds.filter(f => {
|
||||
const hidden = f?.id ? hiddenFeedIds.includes(f?.id || '') : false
|
||||
return !hidden
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function useActiveLiveEventFeedUris() {
|
||||
const {feeds} = useLiveEvents()
|
||||
|
||||
return new Set(
|
||||
feeds
|
||||
// insurance
|
||||
.filter(f => isBskyCustomFeedUrl(f.url))
|
||||
.map(f => {
|
||||
const uri = convertBskyAppUrlIfNeeded(f.url)
|
||||
const [_0, did, _1, rkey] = uri.split('/').filter(Boolean)
|
||||
const urip = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
|
||||
return urip.toString()
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {useEffect} from 'react'
|
||||
import {type Agent, AppBskyActorDefs, asPredicate} from '@atproto/api'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {
|
||||
preferencesQueryKey,
|
||||
usePreferencesQuery,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useAgent} from '#/state/session'
|
||||
import * as env from '#/env'
|
||||
import {
|
||||
type LiveEventFeed,
|
||||
type LiveEventFeedMetricContext,
|
||||
} from '#/features/liveEvents/types'
|
||||
|
||||
export type LiveEventPreferencesAction = Parameters<
|
||||
Agent['updateLiveEventPreferences']
|
||||
>[0] & {
|
||||
/**
|
||||
* Flag that is internal to this hook, do not set when updating prefs
|
||||
*/
|
||||
__canUndo?: boolean
|
||||
}
|
||||
|
||||
export function useLiveEventPreferences() {
|
||||
const query = usePreferencesQuery()
|
||||
useWebOnlyDebugLiveEventPreferences()
|
||||
return {
|
||||
...query,
|
||||
data: query.data?.liveEventPreferences || {
|
||||
hideAllFeeds: false,
|
||||
hiddenFeedIds: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function useWebOnlyDebugLiveEventPreferences() {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
|
||||
useEffect(() => {
|
||||
if (env.IS_DEV && isWeb && typeof window !== 'undefined') {
|
||||
// @ts-ignore
|
||||
window.__updateLiveEventPreferences = async (
|
||||
action: LiveEventPreferencesAction,
|
||||
) => {
|
||||
await agent.updateLiveEventPreferences(action)
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [agent, queryClient])
|
||||
}
|
||||
|
||||
export function useUpdateLiveEventPreferences(props: {
|
||||
feed?: LiveEventFeed
|
||||
metricContext: LiveEventFeedMetricContext
|
||||
onUpdateSuccess?: (props: {
|
||||
undoAction: LiveEventPreferencesAction | null
|
||||
}) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation<
|
||||
AppBskyActorDefs.LiveEventPreferences,
|
||||
Error,
|
||||
LiveEventPreferencesAction,
|
||||
{undoAction: LiveEventPreferencesAction | null}
|
||||
>({
|
||||
onSettled(data, error, variables) {
|
||||
/*
|
||||
* `onSettled` runs after the mutation completes, success or no. The idea
|
||||
* here is that we want to invert the action that was just passed in, and
|
||||
* provide it as an `undoAction` to the `onUpdateSuccess` callback.
|
||||
*
|
||||
* If the operation was not a success, we don't provide the `undoAction`.
|
||||
*
|
||||
* Upon the first call of the mutation, the `__canUndo` flag is undefined,
|
||||
* so we allow the undo. However, when we create the `undoAction`, we
|
||||
* set its `__canUndo` flag to false, so that if the user were to call
|
||||
* the undo action, we would not provide another undo for that.
|
||||
*/
|
||||
const canUndo = variables.__canUndo === undefined ? true : false
|
||||
let undoAction: LiveEventPreferencesAction | null = null
|
||||
|
||||
switch (variables.type) {
|
||||
case 'hideFeed':
|
||||
undoAction = {type: 'unhideFeed', id: variables.id, __canUndo: false}
|
||||
break
|
||||
case 'unhideFeed':
|
||||
undoAction = {type: 'hideFeed', id: variables.id, __canUndo: false}
|
||||
break
|
||||
case 'toggleHideAllFeeds':
|
||||
undoAction = {type: 'toggleHideAllFeeds', __canUndo: false}
|
||||
break
|
||||
}
|
||||
|
||||
if (data && !error) {
|
||||
props?.onUpdateSuccess?.({
|
||||
undoAction: canUndo ? undoAction : null,
|
||||
})
|
||||
}
|
||||
},
|
||||
mutationFn: async action => {
|
||||
const updated = await agent.updateLiveEventPreferences(action)
|
||||
const prefs = updated.find(p =>
|
||||
asPredicate(AppBskyActorDefs.validateLiveEventPreferences)(p),
|
||||
)
|
||||
|
||||
switch (action.type) {
|
||||
case 'hideFeed':
|
||||
case 'unhideFeed': {
|
||||
if (!props.feed) {
|
||||
logger.error(
|
||||
`useUpdateLiveEventPreferences: feed is missing, but required for hiding/unhiding`,
|
||||
{
|
||||
action,
|
||||
},
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
logger.metric(
|
||||
action.type === 'hideFeed'
|
||||
? 'liveEvents:feedBanner:hide'
|
||||
: 'liveEvents:feedBanner:unhide',
|
||||
{
|
||||
feed: props.feed.url,
|
||||
context: props.metricContext,
|
||||
},
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'toggleHideAllFeeds': {
|
||||
if (prefs!.hideAllFeeds) {
|
||||
logger.metric('liveEvents:hideAllFeedBanners', {
|
||||
context: props.metricContext,
|
||||
})
|
||||
} else {
|
||||
logger.metric('liveEvents:unhideAllFeedBanners', {
|
||||
context: props.metricContext,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// triggers a refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
|
||||
return prefs!
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export type LiveEventFeedImageLayout = 'wide' | 'compact' // maybe more in the future
|
||||
|
||||
export type LiveEventFeedLayout = {
|
||||
title: string
|
||||
overlayColor: string
|
||||
textColor: string
|
||||
image: string
|
||||
blurhash: string
|
||||
}
|
||||
|
||||
export type LiveEventFeed = {
|
||||
id: string
|
||||
preview: boolean
|
||||
title: string
|
||||
url: string
|
||||
layouts: Record<LiveEventFeedImageLayout, LiveEventFeedLayout>
|
||||
}
|
||||
|
||||
export type LiveEventsWorkerResponse = {
|
||||
feeds: LiveEventFeed[]
|
||||
}
|
||||
|
||||
export type LiveEventFeedMetricContext =
|
||||
| 'explore'
|
||||
| 'discover'
|
||||
| 'sidebar'
|
||||
| 'settings'
|
||||
@@ -1,7 +1,7 @@
|
||||
import {BAPP_CONFIG_URL} from '#/env'
|
||||
import {GEOLOCATION_URL} from '#/env'
|
||||
import {type Geolocation} from '#/geolocation/types'
|
||||
|
||||
export const GEOLOCATION_SERVICE_URL = `${BAPP_CONFIG_URL}/geolocation`
|
||||
export const GEOLOCATION_SERVICE_URL = `${GEOLOCATION_URL}/geolocation`
|
||||
|
||||
/**
|
||||
* Default geolocation config.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
|
||||
export function useIsBskyTeam() {
|
||||
const gate = useGate()
|
||||
return useMemo(() => gate('is_bsky_team_member'), [gate])
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type Gate =
|
||||
| 'disable_settings_find_contacts'
|
||||
| 'explore_show_suggested_feeds'
|
||||
| 'feed_reply_button_open_thread'
|
||||
| 'is_bsky_team_member' // special, do not remove
|
||||
| 'old_postonboarding'
|
||||
| 'onboarding_add_video_feed'
|
||||
| 'onboarding_suggested_starterpacks'
|
||||
|
||||
@@ -16,6 +16,8 @@ import {enabledLogLevels} from '#/logger/util'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {ENV} from '#/env'
|
||||
|
||||
export {type MetricEvents as Metrics} from '#/logger/metrics'
|
||||
|
||||
const TRANSPORTS: Transport[] = (function configureTransports() {
|
||||
switch (ENV) {
|
||||
case 'production': {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
|
||||
|
||||
export type MetricEvents = {
|
||||
// App events
|
||||
@@ -796,4 +797,27 @@ export type MetricEvents = {
|
||||
}
|
||||
// user pressed the remove all data button
|
||||
'contacts:settings:removeData': {}
|
||||
|
||||
'liveEvents:feedBanner:seen': {
|
||||
feed: string
|
||||
context: LiveEventFeedMetricContext
|
||||
}
|
||||
'liveEvents:feedBanner:click': {
|
||||
feed: string
|
||||
context: LiveEventFeedMetricContext
|
||||
}
|
||||
'liveEvents:feedBanner:hide': {
|
||||
feed: string
|
||||
context: LiveEventFeedMetricContext
|
||||
}
|
||||
'liveEvents:feedBanner:unhide': {
|
||||
feed: string
|
||||
context: LiveEventFeedMetricContext
|
||||
}
|
||||
'liveEvents:hideAllFeedBanners': {
|
||||
context: LiveEventFeedMetricContext
|
||||
}
|
||||
'liveEvents:unhideAllFeedBanners': {
|
||||
context: LiveEventFeedMetricContext
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import {Loader} from '#/components/Loader'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ExploreScreenLiveEventFeedsBanner} from '#/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner'
|
||||
import * as ModuleHeader from './components/ModuleHeader'
|
||||
import {
|
||||
SuggestedAccountsTabBar,
|
||||
@@ -201,6 +202,10 @@ type ExploreScreenItems =
|
||||
type: 'interests-card'
|
||||
key: 'interests-card'
|
||||
}
|
||||
| {
|
||||
type: 'liveEventFeedsBanner'
|
||||
key: string
|
||||
}
|
||||
|
||||
export function Explore({
|
||||
focusSearchInput,
|
||||
@@ -684,6 +689,8 @@ export function Explore({
|
||||
i.push(topBorder)
|
||||
i.push(...interestsNuxModule)
|
||||
|
||||
i.push({type: 'liveEventFeedsBanner', key: 'liveEventFeedsBanner'})
|
||||
|
||||
if (useFullExperience) {
|
||||
i.push(trendingTopicsModule)
|
||||
i.push(...suggestedFeedsModule)
|
||||
@@ -998,6 +1005,9 @@ export function Explore({
|
||||
case 'interests-card': {
|
||||
return <ExploreInterestsCard />
|
||||
}
|
||||
case 'liveEventFeedsBanner': {
|
||||
return <ExploreScreenLiveEventFeedsBanner />
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -26,6 +26,7 @@ import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
|
||||
import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {LiveEventFeedsSettingsToggle} from '#/features/liveEvents/components/LiveEventFeedsSettingsToggle'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
@@ -124,7 +125,7 @@ export function ContentAndMediaSettingsScreen({}: Props) {
|
||||
<Toggle.Platform />
|
||||
</SettingsList.Item>
|
||||
</Toggle.Item>
|
||||
{trendingEnabled && (
|
||||
{trendingEnabled ? (
|
||||
<>
|
||||
<SettingsList.Divider />
|
||||
<Toggle.Item
|
||||
@@ -148,6 +149,7 @@ export function ContentAndMediaSettingsScreen({}: Props) {
|
||||
<Toggle.Platform />
|
||||
</SettingsList.Item>
|
||||
</Toggle.Item>
|
||||
<LiveEventFeedsSettingsToggle />
|
||||
<Toggle.Item
|
||||
name="show_trending_videos"
|
||||
label={_(msg`Enable trending videos in your Discover feed`)}
|
||||
@@ -170,6 +172,11 @@ export function ContentAndMediaSettingsScreen({}: Props) {
|
||||
</SettingsList.Item>
|
||||
</Toggle.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SettingsList.Divider />
|
||||
<LiveEventFeedsSettingsToggle />
|
||||
</>
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
|
||||
@@ -45,4 +45,8 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
|
||||
verificationPrefs: {
|
||||
hideBadges: false,
|
||||
},
|
||||
liveEventPreferences: {
|
||||
hideAllFeeds: false,
|
||||
hiddenFeedIds: [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -148,8 +148,6 @@ export function ComposerPrompt() {
|
||||
a.relative,
|
||||
a.flex_row,
|
||||
a.align_start,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
{
|
||||
paddingLeft: 18,
|
||||
paddingRight: 15,
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
} from '#/components/feeds/PostFeedVideoGridRow'
|
||||
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
||||
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
|
||||
import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner'
|
||||
import {ComposerPrompt} from '../feeds/ComposerPrompt'
|
||||
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
||||
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
||||
@@ -155,6 +156,10 @@ type FeedRow =
|
||||
type: 'composerPrompt'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'liveEventFeedsAndTrendingBanner'
|
||||
key: string
|
||||
}
|
||||
|
||||
export function getItemsForFeedback(feedRow: FeedRow): {
|
||||
item: FeedPostSliceItem
|
||||
@@ -360,7 +365,7 @@ let PostFeed = ({
|
||||
const showProgressIntersitial =
|
||||
(followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible
|
||||
|
||||
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
|
||||
const {trendingVideoDisabled} = useTrendingSettings()
|
||||
|
||||
const ageAssuranceBannerState = useAgeAssuranceBannerState()
|
||||
const selectedFeed = useSelectedFeed()
|
||||
@@ -510,13 +515,10 @@ let PostFeed = ({
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!rightNavVisible && !trendingDisabled) {
|
||||
arr.push({
|
||||
type: 'interstitialTrending',
|
||||
key:
|
||||
'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
|
||||
})
|
||||
}
|
||||
arr.push({
|
||||
type: 'liveEventFeedsAndTrendingBanner',
|
||||
key: 'liveEventFeedsAndTrendingBanner-' + sliceIndex,
|
||||
})
|
||||
// Show composer prompt for Discover and Following feeds
|
||||
if (
|
||||
hasSession &&
|
||||
@@ -672,9 +674,7 @@ let PostFeed = ({
|
||||
feedTab,
|
||||
hasSession,
|
||||
showProgressIntersitial,
|
||||
trendingDisabled,
|
||||
trendingVideoDisabled,
|
||||
rightNavVisible,
|
||||
gtMobile,
|
||||
isVideoFeed,
|
||||
areVideoFeedsEnabled,
|
||||
@@ -773,6 +773,8 @@ let PostFeed = ({
|
||||
return <AgeAssuranceDismissibleFeedBanner />
|
||||
} else if (row.type === 'interstitialTrending') {
|
||||
return <TrendingInterstitial />
|
||||
} else if (row.type === 'liveEventFeedsAndTrendingBanner') {
|
||||
return <DiscoverFeedLiveEventFeedsAndTrendingBanner />
|
||||
} else if (row.type === 'composerPrompt') {
|
||||
return <ComposerPrompt />
|
||||
} else if (row.type === 'interstitialTrendingVideos') {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {CENTER_COLUMN_OFFSET} from '#/components/Layout'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {ProgressGuideList} from '#/components/ProgressGuide/List'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {SidebarLiveEventFeedsBanner} from '#/features/liveEvents/components/SidebarLiveEventFeedsBanner'
|
||||
|
||||
function useWebQueryParams() {
|
||||
const navigation = useNavigation()
|
||||
@@ -49,7 +50,8 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
const isSearchScreen = routeName === 'Search'
|
||||
const webqueryParams = useWebQueryParams()
|
||||
const searchQuery = webqueryParams?.q
|
||||
const showTrending = !isSearchScreen || (isSearchScreen && !!searchQuery)
|
||||
const showExploreScreenDuplicatedContent =
|
||||
!isSearchScreen || (isSearchScreen && !!searchQuery)
|
||||
const {rightNavVisible, centerColumnOffset, leftNavMinimal} =
|
||||
useLayoutBreakpoints()
|
||||
|
||||
@@ -90,7 +92,8 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTrending && <SidebarTrendingTopics />}
|
||||
{showExploreScreenDuplicatedContent && <SidebarLiveEventFeedsBanner />}
|
||||
{showExploreScreenDuplicatedContent && <SidebarTrendingTopics />}
|
||||
|
||||
<Text style={[a.leading_snug, t.atoms.text_contrast_low]}>
|
||||
{hasSession && (
|
||||
|
||||
Reference in New Issue
Block a user