[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:
@@ -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'
|
||||
Reference in New Issue
Block a user