Add sidebar cards, update types

This commit is contained in:
Eric Bailey
2026-01-14 12:48:44 -06:00
parent 907229c6b4
commit 3be51f5698
6 changed files with 162 additions and 107 deletions
@@ -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 {gtPhone} = useBreakpoints()
const image = feed.images.wide
const overlayColor = image.overlayColor
const textColor = image.textColor
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)) {
@@ -71,8 +71,8 @@ export function LiveEventFeedCardWide({
<Image
accessibilityIgnoresInvertColors
source={{
uri: image.url,
blurhash: image.blurhash,
uri: layout.image,
blurhash: layout.blurhash,
}}
style={[a.absolute, a.inset_0]}
contentFit="cover"
@@ -128,7 +128,7 @@ export function LiveEventFeedCardWide({
gtPhone ? a.text_3xl : a.text_lg,
{color: textColor},
]}>
{feed.title}
{layout.title}
</Text>
</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"
/>
))
}
-94
View File
@@ -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>
)
}
+5 -5
View File
@@ -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 = {
alt: string
export type LiveEventFeedLayout = {
title: string
overlayColor: string
textColor: string
url: string
image: string
blurhash: string
}
@@ -13,7 +13,7 @@ export type LiveEventFeed = {
preview: boolean
title: string
url: string
images: Record<LiveEventFeedImageLayout, LiveEventFeedImage>
layouts: Record<LiveEventFeedImageLayout, LiveEventFeedLayout>
}
export type LiveEventsWorkerResponse = {
+5 -2
View File
@@ -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 && (