diff --git a/.env.example b/.env.example
index 96a1548d66..e90541cabb 100644
--- a/.env.example
+++ b/.env.example
@@ -34,5 +34,8 @@ EXPO_PUBLIC_SENTRY_DSN=
# Bitdrift API key. If undefined, Bitdrift will be disabled.
EXPO_PUBLIC_BITDRIFT_API_KEY=
-# bapp-config web worker URL
-BAPP_CONFIG_DEV_URL=
+# geolocation web worker URL
+GEOLOCATION_DEV_URL=
+
+# live-events web worker URL
+LIVE_EVENTS_DEV_URL=
diff --git a/.eslintrc.js b/.eslintrc.js
index 37ed895aa4..16e844a050 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -35,6 +35,7 @@ module.exports = {
'Admonition',
'Admonition.Admonition',
'Toast.Action',
+ 'toast.Action',
'AgeAssuranceAdmonition',
'Span',
'StackedButton',
diff --git a/package.json b/package.json
index 61e042e05b..32b78e18fb 100644
--- a/package.json
+++ b/package.json
@@ -73,7 +73,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
- "@atproto/api": "^0.18.13",
+ "@atproto/api": "^0.18.15",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 34c3cc204c..2c4d6fa413 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)
@@ -141,49 +146,51 @@ function InnerApp() {
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 956c52005c..460d9ff174 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)
@@ -117,45 +122,47 @@ function InnerApp() {
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/components/FeedCard.tsx b/src/components/FeedCard.tsx
index c5e6200527..1fcedc6916 100644
--- a/src/components/FeedCard.tsx
+++ b/src/components/FeedCard.tsx
@@ -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) {
@@ -118,14 +124,40 @@ export function AvatarPlaceholder({size = 40}: Omit) {
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 (
+ {uri && activeLiveEvents.has(uri) && (
+
+
+
+ Happening now
+
+
+ )}
+ style={[a.border, t.atoms.border_contrast_low, a.p_sm, a.rounded_md]}>
-
+
-
)
diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx
index 3d47385ee8..830dfb3b66 100644
--- a/src/components/interstitials/Trending.tsx
+++ b/src/components/interstitials/Trending.tsx
@@ -41,7 +41,7 @@ export function Inner() {
}, [setTrendingDisabled])
return error || noTopics ? null : (
-
+
+ } else {
+ // no feed, no trending
+ return null
+ }
+ }
+
+ // On desktop, we show in the sidebar
+ if (rightNavVisible) return null
+
+ return events.feeds.map(feed => )
+}
+
+function Inner({feed}: {feed: LiveEventFeed}) {
+ const {_} = useLingui()
+ const optionsMenuControl = useDialogControl()
+ const layout = feed.layouts.wide
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner.tsx b/src/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner.tsx
new file mode 100644
index 0000000000..bf80ed6a2e
--- /dev/null
+++ b/src/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner.tsx
@@ -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 => (
+
+
+
+ ))
+}
diff --git a/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx b/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx
new file mode 100644
index 0000000000..69c24b2dc5
--- /dev/null
+++ b/src/features/liveEvents/components/LiveEventFeedCardCompact.tsx
@@ -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 (
+ {
+ logger.metric('liveEvents:feedBanner:click', {
+ feed: feed.url,
+ context: metricContext,
+ })
+ }}>
+ {({hovered, pressed}) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ {layout.title}
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/features/liveEvents/components/LiveEventFeedCardWide.tsx b/src/features/liveEvents/components/LiveEventFeedCardWide.tsx
new file mode 100644
index 0000000000..1ed3a49df2
--- /dev/null
+++ b/src/features/liveEvents/components/LiveEventFeedCardWide.tsx
@@ -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 (
+ {
+ logger.metric('liveEvents:feedBanner:click', {
+ feed: feed.url,
+ context: metricContext,
+ })
+ }}>
+ {({hovered, pressed}) => (
+
+
+
+
+
+
+
+
+
+
+
+ {feed.preview ? (
+ Preview
+ ) : (
+ Happening now
+ )}
+
+
+ {layout.title}
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx b/src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx
new file mode 100644
index 0000000000..b2f50840a9
--- /dev/null
+++ b/src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx
@@ -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 (
+
+
+
+
+
+
+
+ )
+}
+
+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(
+
+
+
+ Your live event preferences have been updated.
+
+ {undoAction && (
+ {
+ if (undoAction) {
+ update(undoAction)
+ }
+ }}>
+ Undo
+
+ )}
+ ,
+ {
+ 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 (
+
+
+
+ Live event options
+
+
+
+
+ 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.
+
+
+
+
+
+ If you choose to hide all events, you can always re-enable them from{' '}
+ Settings → Content & Media.
+
+
+
+
+
+
+
+ {isNative && (
+
+ )}
+
+
+ {error && (
+
+ {error.clean || error.raw || _(msg`An unknown error occurred.`)}
+
+ )}
+
+ )
+}
diff --git a/src/features/liveEvents/components/LiveEventFeedsSettingsToggle.tsx b/src/features/liveEvents/components/LiveEventFeedsSettingsToggle.tsx
new file mode 100644
index 0000000000..35d4039ce1
--- /dev/null
+++ b/src/features/liveEvents/components/LiveEventFeedsSettingsToggle.tsx
@@ -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 (
+ {
+ if (!isPending) {
+ update({type: 'toggleHideAllFeeds'})
+ }
+ }}>
+
+
+
+ Show live events in your Discover Feed
+
+
+
+
+ )
+}
diff --git a/src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx b/src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx
new file mode 100644
index 0000000000..25a5db8c8c
--- /dev/null
+++ b/src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx
@@ -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 => (
+
+ ))
+}
diff --git a/src/features/liveEvents/context.tsx b/src/features/liveEvents/context.tsx
new file mode 100644
index 0000000000..fa2aba2260
--- /dev/null
+++ b/src/features/liveEvents/context.tsx
@@ -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 {
+ 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(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 {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
+}
+
+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()
+ }),
+ )
+}
diff --git a/src/features/liveEvents/preferences.ts b/src/features/liveEvents/preferences.ts
new file mode 100644
index 0000000000..2f7af094a8
--- /dev/null
+++ b/src/features/liveEvents/preferences.ts
@@ -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!
+ },
+ })
+}
diff --git a/src/features/liveEvents/types.ts b/src/features/liveEvents/types.ts
new file mode 100644
index 0000000000..8bc88c448c
--- /dev/null
+++ b/src/features/liveEvents/types.ts
@@ -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
+}
+
+export type LiveEventsWorkerResponse = {
+ feeds: LiveEventFeed[]
+}
+
+export type LiveEventFeedMetricContext =
+ | 'explore'
+ | 'discover'
+ | 'sidebar'
+ | 'settings'
diff --git a/src/geolocation/const.ts b/src/geolocation/const.ts
index b12f37140f..653e829bad 100644
--- a/src/geolocation/const.ts
+++ b/src/geolocation/const.ts
@@ -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.
diff --git a/src/lib/hooks/useIsBskyTeam.ts b/src/lib/hooks/useIsBskyTeam.ts
new file mode 100644
index 0000000000..d653e7fd5e
--- /dev/null
+++ b/src/lib/hooks/useIsBskyTeam.ts
@@ -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])
+}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 357548d3b1..dcb330764c 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -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'
diff --git a/src/logger/index.ts b/src/logger/index.ts
index 998d025818..57d430b5dd 100644
--- a/src/logger/index.ts
+++ b/src/logger/index.ts
@@ -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': {
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index 0af92a6e7c..7afe7f3bbe 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -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
+ }
}
diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx
index b3e26dcfd3..6b49588db9 100644
--- a/src/screens/Search/Explore.tsx
+++ b/src/screens/Search/Explore.tsx
@@ -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
}
+ case 'liveEventFeedsBanner': {
+ return
+ }
}
},
[
diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx
index e1144d0645..ebcf95a7af 100644
--- a/src/screens/Settings/ContentAndMediaSettings.tsx
+++ b/src/screens/Settings/ContentAndMediaSettings.tsx
@@ -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) {
- {trendingEnabled && (
+ {trendingEnabled ? (
<>
+
>
+ ) : (
+ <>
+
+
+ >
)}
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts
index 54d063a502..7508fa4f6d 100644
--- a/src/state/queries/preferences/const.ts
+++ b/src/state/queries/preferences/const.ts
@@ -45,4 +45,8 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
verificationPrefs: {
hideBadges: false,
},
+ liveEventPreferences: {
+ hideAllFeeds: false,
+ hiddenFeedIds: [],
+ },
}
diff --git a/src/view/com/feeds/ComposerPrompt.tsx b/src/view/com/feeds/ComposerPrompt.tsx
index be88ee2dc1..5d4685a4e6 100644
--- a/src/view/com/feeds/ComposerPrompt.tsx
+++ b/src/view/com/feeds/ComposerPrompt.tsx
@@ -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,
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
index 4f25468c95..9e6fcdcccb 100644
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -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
} else if (row.type === 'interstitialTrending') {
return
+ } else if (row.type === 'liveEventFeedsAndTrendingBanner') {
+ return
} else if (row.type === 'composerPrompt') {
return
} else if (row.type === 'interstitialTrendingVideos') {
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index 788df6e64c..04dddaf739 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -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 && }
+ {showExploreScreenDuplicatedContent && }
+ {showExploreScreenDuplicatedContent && }
{hasSession && (
diff --git a/yarn.lock b/yarn.lock
index d0741715da..ff0610ac4b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -82,12 +82,12 @@
"@atproto/xrpc" "^0.7.6"
"@atproto/xrpc-server" "^0.10.0"
-"@atproto/api@^0.18.13":
- version "0.18.13"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.13.tgz#63eee310e6715752eb87748323cf9ab57dd91e4b"
- integrity sha512-CULZ01pSJDltLS/Gc9MMrhFzB6OM3ezyZw7KoeLT/sBfwgA1ddA4mWdTh7DIRosPRigXtA05bnoiCutZbQDo+Q==
+"@atproto/api@^0.18.15":
+ version "0.18.15"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.15.tgz#25ce82081216bdefbf5397220de76ac3ba9e3f5d"
+ integrity sha512-GeaTP7HMRZa8jD6trMuTACa8t2jkFtRmcwWgrB0FT7l9jVCXrKpYupWeIeauEgWHNwWUUiaq3LmCox+HBy8ZMQ==
dependencies:
- "@atproto/common-web" "^0.4.11"
+ "@atproto/common-web" "^0.4.12"
"@atproto/lexicon" "^0.6.0"
"@atproto/syntax" "^0.4.2"
"@atproto/xrpc" "^0.7.7"
@@ -208,13 +208,13 @@
pino-http "^8.2.1"
typed-emitter "^2.1.0"
-"@atproto/common-web@^0.4.11":
- version "0.4.11"
- resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.11.tgz#eb41dc02c1ea4221388630e193d181fb098186e0"
- integrity sha512-VHejNmSABU8/03VrQ3e36AmT5U3UIeio+qSUqCrO1oNgrJcWfGy1rpj0FVtUugWF8Un29+yzkukzWGZfXL70rQ==
+"@atproto/common-web@^0.4.12":
+ version "0.4.12"
+ resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.12.tgz#04135bef480d9e12cfef124ee45d8236764e7509"
+ integrity sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw==
dependencies:
- "@atproto/lex-data" "0.0.7"
- "@atproto/lex-json" "0.0.7"
+ "@atproto/lex-data" "0.0.8"
+ "@atproto/lex-json" "0.0.8"
zod "^3.23.8"
"@atproto/common-web@^0.4.4", "@atproto/common-web@^0.4.6":
@@ -415,10 +415,10 @@
uint8arrays "3.0.0"
unicode-segmenter "^0.14.0"
-"@atproto/lex-data@0.0.7":
- version "0.0.7"
- resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.7.tgz#6aa87423f6d47849bec8ff3ca0b00ce93964adc8"
- integrity sha512-W/Q5o9o7n2Sv3UywckChu01X5lwQUtaiiOkGJLnRsdkQTyC6813nPgY+p2sG7NwwM+82lu+FUV9fE/Ul3VqaJw==
+"@atproto/lex-data@0.0.8":
+ version "0.0.8"
+ resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.8.tgz#46cc261efbfa6cc05bf04439d2d73cd8386b467d"
+ integrity sha512-1Y5tz7BkS7380QuLNXaE8GW8Xba+mRWugt8BKM4BUFYjjUZdmirU8lr72iM4XlEBrzRu8Cfvj+MbsbYaZv+IgA==
dependencies:
"@atproto/syntax" "0.4.2"
multiformats "^9.9.0"
@@ -451,12 +451,12 @@
"@atproto/lex-data" "0.0.3"
tslib "^2.8.1"
-"@atproto/lex-json@0.0.7":
- version "0.0.7"
- resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.7.tgz#c06e1fc3e06d739bbb74694f5d846055bed37866"
- integrity sha512-bjNPD5M/MhLfjNM7tcxuls80UgXpHqxdOxDXEUouAtZQV/nIDhGjmNUvKxOmOgnDsiZRnT2g5y3onrnjH3a44g==
+"@atproto/lex-json@0.0.8":
+ version "0.0.8"
+ resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.8.tgz#03290762d9368b029488ee0a0766d1a34063255c"
+ integrity sha512-w1Qmkae1QhmNz+i1Zm3xr3jp0UPPRENmdlpU0qIrdxWDo9W4Mzkeyc3eSoa+Zs+zN8xkRSQw7RLZte/B7Ipdwg==
dependencies:
- "@atproto/lex-data" "0.0.7"
+ "@atproto/lex-data" "0.0.8"
tslib "^2.8.1"
"@atproto/lex-resolver@0.0.5":