diff --git a/src/App.native.tsx b/src/App.native.tsx
index 34c3cc204c..02f0179906 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -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)
@@ -230,7 +235,9 @@ function App() {
-
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 956c52005c..d31bb56010 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -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)
@@ -198,7 +203,9 @@ function App() {
-
+
+
+
diff --git a/src/alf/index.tsx b/src/alf/index.tsx
index eed3fcbeb2..3aff9ddb57 100644
--- a/src/alf/index.tsx
+++ b/src/alf/index.tsx
@@ -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'
diff --git a/src/features/liveEvents/context.tsx b/src/features/liveEvents/context.tsx
new file mode 100644
index 0000000000..4cd8520962
--- /dev/null
+++ b/src/features/liveEvents/context.tsx
@@ -0,0 +1,55 @@
+import {createContext, useContext} from 'react'
+import {QueryClient, useQuery} from '@tanstack/react-query'
+
+import {IS_DEV, LIVE_EVENTS_URL} from '#/env'
+import {type LiveEventsWorkerResponse} from '#/features/liveEvents/types'
+
+const qc = new QueryClient()
+const liveEventsQueryKey = ['live-events']
+
+async function fetchLiveEvents(): Promise {
+ 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({
+ feeds: [],
+})
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+ const {data} = useQuery(
+ {
+ staleTime: IS_DEV ? 5e3 : 1000 * 60,
+ queryKey: liveEventsQueryKey,
+ async queryFn() {
+ return fetchLiveEvents()
+ },
+ },
+ qc,
+ )
+
+ return (
+ {children}
+ )
+}
+
+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
+}
diff --git a/src/features/liveEvents/index.tsx b/src/features/liveEvents/index.tsx
new file mode 100644
index 0000000000..856e4a4fb7
--- /dev/null
+++ b/src/features/liveEvents/index.tsx
@@ -0,0 +1,94 @@
+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>
+}
+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 {
+ const res = await fetch(`${LIVE_EVENTS_URL}/config`)
+ if (!res.ok) return null
+ return res.json()
+}
+
+export function getLiveEventsCache() {
+ return qc.getQueryData(liveEventsQueryKey)
+}
+
+export async function prefetchLiveEvents() {
+ const data = await getLiveEvents()
+ if (data) {
+ qc.setQueryData(liveEventsQueryKey, data)
+ }
+}
+
+export const Context = createContext({
+ 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 (
+ {children}
+ )
+}
diff --git a/src/features/liveEvents/types.ts b/src/features/liveEvents/types.ts
new file mode 100644
index 0000000000..faabcbe9f5
--- /dev/null
+++ b/src/features/liveEvents/types.ts
@@ -0,0 +1,18 @@
+export type LiveEventFeedImageLayout = 'wide' // maybe more in the future
+
+export type LiveEventFeedImage = {
+ alt: string
+ overlayColor: string
+ url: string
+ blurhash: string
+}
+
+export type LiveEventFeed = {
+ title: string
+ url: string
+ images: Record
+}
+
+export type LiveEventsWorkerResponse = {
+ feeds: LiveEventFeed[]
+}