Add sidebar cards, update types
This commit is contained in:
@@ -0,0 +1,133 @@
|
|||||||
|
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,
|
||||||
|
blurhash: layout.blurhash,
|
||||||
|
}}
|
||||||
|
style={[a.absolute, a.inset_0]}
|
||||||
|
contentFit="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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -27,9 +27,9 @@ export function LiveEventFeedCardWide({
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {gtPhone} = useBreakpoints()
|
const {gtPhone} = useBreakpoints()
|
||||||
|
|
||||||
const image = feed.images.wide
|
const layout = feed.layouts.wide
|
||||||
const overlayColor = image.overlayColor
|
const overlayColor = layout.overlayColor
|
||||||
const textColor = image.textColor
|
const textColor = layout.textColor
|
||||||
const url = useMemo(() => {
|
const url = useMemo(() => {
|
||||||
// Validated in multiple places on the backend
|
// Validated in multiple places on the backend
|
||||||
if (isBskyCustomFeedUrl(feed.url)) {
|
if (isBskyCustomFeedUrl(feed.url)) {
|
||||||
@@ -71,8 +71,8 @@ export function LiveEventFeedCardWide({
|
|||||||
<Image
|
<Image
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityIgnoresInvertColors
|
||||||
source={{
|
source={{
|
||||||
uri: image.url,
|
uri: layout.image,
|
||||||
blurhash: image.blurhash,
|
blurhash: layout.blurhash,
|
||||||
}}
|
}}
|
||||||
style={[a.absolute, a.inset_0]}
|
style={[a.absolute, a.inset_0]}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
@@ -128,7 +128,7 @@ export function LiveEventFeedCardWide({
|
|||||||
gtPhone ? a.text_3xl : a.text_lg,
|
gtPhone ? a.text_3xl : a.text_lg,
|
||||||
{color: textColor},
|
{color: textColor},
|
||||||
]}>
|
]}>
|
||||||
{feed.title}
|
{layout.title}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import {createContext, useContext} from 'react'
|
|
||||||
import {QueryClient, useQuery} from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import {IS_DEV, LIVE_EVENTS_URL} from '#/env'
|
|
||||||
|
|
||||||
type LiveEventFeedImageLayout = 'wide' // maybe more in the future
|
|
||||||
type LiveEventFeedImage = {
|
|
||||||
alt: string
|
|
||||||
overlayColor: string
|
|
||||||
url: string
|
|
||||||
blurhash: string
|
|
||||||
}
|
|
||||||
type LiveEventFeed = {
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
images: Partial<Record<LiveEventFeedImageLayout, LiveEventFeedImage>>
|
|
||||||
}
|
|
||||||
type LiveEventsConfig = {
|
|
||||||
feeds: LiveEventFeed[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const qc = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
/**
|
|
||||||
* We clear this manually, so disable automatic garbage collection.
|
|
||||||
* @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works
|
|
||||||
*/
|
|
||||||
gcTime: Infinity,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const liveEventsQueryKey = ['live-events']
|
|
||||||
|
|
||||||
async function getLiveEvents(): Promise<LiveEventsConfig | null> {
|
|
||||||
const res = await fetch(`${LIVE_EVENTS_URL}/config`)
|
|
||||||
if (!res.ok) return null
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLiveEventsCache() {
|
|
||||||
return qc.getQueryData<LiveEventsConfig>(liveEventsQueryKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function prefetchLiveEvents() {
|
|
||||||
const data = await getLiveEvents()
|
|
||||||
if (data) {
|
|
||||||
qc.setQueryData(liveEventsQueryKey, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Context = createContext<LiveEventsConfig>({
|
|
||||||
feeds: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
export function useLiveEventsContext() {
|
|
||||||
const ctx = useContext(Context)
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useLiveEventsContext must be used within a Provider')
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
|
||||||
const {data} = useQuery(
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Will re-fetch when stale, at most every minute (or 5s in dev for easier
|
|
||||||
* testing).
|
|
||||||
*
|
|
||||||
* @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
|
|
||||||
*/
|
|
||||||
staleTime: IS_DEV ? 5e3 : 1000 * 60,
|
|
||||||
/**
|
|
||||||
* N.B. if prefetch failed above, we'll have no `initialData`, and this
|
|
||||||
* query will run on startup.
|
|
||||||
*/
|
|
||||||
initialData: getLiveEventsCache(),
|
|
||||||
initialDataUpdatedAt: () =>
|
|
||||||
qc.getQueryState(liveEventsQueryKey)?.dataUpdatedAt,
|
|
||||||
queryKey: liveEventsQueryKey,
|
|
||||||
async queryFn() {
|
|
||||||
console.debug(`live-events: fetching config`)
|
|
||||||
return getLiveEvents()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
qc,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Context.Provider value={data || {feeds: []}}>{children}</Context.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
export type LiveEventFeedImageLayout = 'wide' // maybe more in the future
|
export type LiveEventFeedImageLayout = 'wide' | 'compact' // maybe more in the future
|
||||||
|
|
||||||
export type LiveEventFeedImage = {
|
export type LiveEventFeedLayout = {
|
||||||
alt: string
|
title: string
|
||||||
overlayColor: string
|
overlayColor: string
|
||||||
textColor: string
|
textColor: string
|
||||||
url: string
|
image: string
|
||||||
blurhash: string
|
blurhash: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ export type LiveEventFeed = {
|
|||||||
preview: boolean
|
preview: boolean
|
||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
images: Record<LiveEventFeedImageLayout, LiveEventFeedImage>
|
layouts: Record<LiveEventFeedImageLayout, LiveEventFeedLayout>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LiveEventsWorkerResponse = {
|
export type LiveEventsWorkerResponse = {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {CENTER_COLUMN_OFFSET} from '#/components/Layout'
|
|||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
import {ProgressGuideList} from '#/components/ProgressGuide/List'
|
import {ProgressGuideList} from '#/components/ProgressGuide/List'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {SidebarLiveEventFeedsBanner} from '#/features/liveEvents/components/SidebarLiveEventFeedsBanner'
|
||||||
|
|
||||||
function useWebQueryParams() {
|
function useWebQueryParams() {
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
@@ -49,7 +50,8 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
|||||||
const isSearchScreen = routeName === 'Search'
|
const isSearchScreen = routeName === 'Search'
|
||||||
const webqueryParams = useWebQueryParams()
|
const webqueryParams = useWebQueryParams()
|
||||||
const searchQuery = webqueryParams?.q
|
const searchQuery = webqueryParams?.q
|
||||||
const showTrending = !isSearchScreen || (isSearchScreen && !!searchQuery)
|
const showExploreScreenDuplicatedContent =
|
||||||
|
!isSearchScreen || (isSearchScreen && !!searchQuery)
|
||||||
const {rightNavVisible, centerColumnOffset, leftNavMinimal} =
|
const {rightNavVisible, centerColumnOffset, leftNavMinimal} =
|
||||||
useLayoutBreakpoints()
|
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]}>
|
<Text style={[a.leading_snug, t.atoms.text_contrast_low]}>
|
||||||
{hasSession && (
|
{hasSession && (
|
||||||
|
|||||||
Reference in New Issue
Block a user