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/src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx b/src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx
index 32819f3b3b..9f467d6542 100644
--- a/src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx
+++ b/src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx
@@ -5,12 +5,15 @@ import {useLingui} from '@lingui/react'
import {useTrendingSettings} from '#/state/preferences/trending'
import {atoms as a, useLayoutBreakpoints, useTheme} from '#/alf'
import {Button} from '#/components/Button'
-import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
+import {DotGrid_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {TrendingInterstitial} from '#/components/interstitials/Trending'
-import * as Prompt from '#/components/Prompt'
import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide'
+import {
+ LiveEventFeedOptionsMenu,
+ useDialogControl,
+} from '#/features/liveEvents/components/LiveEventFeedOptionsMenu'
import {useLiveEvents} from '#/features/liveEvents/context'
-import {device, useStorage} from '#/storage'
+import {useLiveEventPreferences} from '#/features/liveEvents/preferences'
export function DiscoverFeedLiveEventFeedsAndTrendingBanner() {
const t = useTheme()
@@ -18,14 +21,17 @@ export function DiscoverFeedLiveEventFeedsAndTrendingBanner() {
const events = useLiveEvents()
const {rightNavVisible} = useLayoutBreakpoints()
const {trendingDisabled} = useTrendingSettings()
- const promptControl = Prompt.usePromptControl()
- const [bannerDisabled, setBannerDisabled] = useStorage(device, [
- 'liveEventFeedsBannerDisabled',
- ])
+ const optionsMenuControl = useDialogControl()
+ const {data, isLoading} = useLiveEventPreferences()
+ // user prefs should be loaded by now, but for TS-sake
+ if (isLoading) return null
+
+ const {hideAllFeeds, hiddenFeedIds} = data
const feed = events.feeds.at(0)
+ const feedHidden = feed?.id ? hiddenFeedIds.includes(feed?.id || '') : false
- if (!feed || bannerDisabled) {
+ if (!feed || hideAllFeeds || feedHidden) {
if (!rightNavVisible && !trendingDisabled) {
// only show trending on mobile when live event banner is not shown
return
@@ -52,7 +58,7 @@ export function DiscoverFeedLiveEventFeedsAndTrendingBanner() {
shape="round"
style={[a.absolute, a.z_10, {top: 6, right: 6}]}
onPress={() => {
- promptControl.open()
+ optionsMenuControl.open()
}}>
{({hovered, pressed}) => (
<>
@@ -67,8 +73,8 @@ export function DiscoverFeedLiveEventFeedsAndTrendingBanner() {
},
]}
/>
-
- {
- setBannerDisabled(true)
- }}
- />
+
>
)
}
diff --git a/src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx b/src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx
new file mode 100644
index 0000000000..e3c882fc9f
--- /dev/null
+++ b/src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx
@@ -0,0 +1,167 @@
+import {useRef} from 'react'
+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 {
+ type LiveEventPreferencesAction,
+ useUpdateLiveEventPreferences,
+} from '#/features/liveEvents/preferences'
+import {type LiveEventFeed} from '#/features/liveEvents/types'
+
+export {useDialogControl} from '#/components/Dialog'
+
+export function LiveEventFeedOptionsMenu({
+ control,
+ feed,
+}: {
+ control: Dialog.DialogControlProps
+ feed: LiveEventFeed
+}) {
+ const {_} = useLingui()
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+function Inner({
+ control,
+ feed,
+}: {
+ control: Dialog.DialogControlProps
+ feed: LiveEventFeed
+}) {
+ const {_} = useLingui()
+ const canUndo = useRef(true)
+ const undoAction = useRef(null)
+ const {
+ isPending,
+ mutate: update,
+ error: rawError,
+ variables,
+ } = useUpdateLiveEventPreferences({
+ onSuccess() {
+ toast.show(
+
+
+
+ Your live event preferences have been updated.
+
+ {canUndo.current && undoAction.current && (
+ {
+ if (undoAction.current) {
+ update(undoAction.current)
+ }
+ }}>
+ Undo
+
+ )}
+ ,
+ {
+ type: 'success',
+ },
+ )
+
+ // must protect to avoid closing an already closed dialog
+ if (canUndo.current) {
+ canUndo.current = false
+ 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.
+
+
+
+
+
+ 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/preferences.ts b/src/features/liveEvents/preferences.ts
new file mode 100644
index 0000000000..94771ce3ff
--- /dev/null
+++ b/src/features/liveEvents/preferences.ts
@@ -0,0 +1,87 @@
+import {useEffect} from 'react'
+import {type Agent} 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'
+
+export type LiveEventPreferencesAction = Parameters<
+ Agent['updateLiveEventPreferences']
+>[0]
+
+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 (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?: {
+ onSuccess?: () => void
+ onError?: (error: Error) => void
+}) {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+
+ return useMutation({
+ onError: props?.onError,
+ onSuccess: props?.onSuccess,
+ mutationFn: async action => {
+ const updated = await agent.updateLiveEventPreferences(action)
+
+ switch (action.type) {
+ case 'hideFeed': {
+ logger.metric('liveEventFeeds:hideFeed', {feedId: action.id})
+ break
+ }
+ case 'unhideFeed': {
+ logger.metric('liveEventFeeds:unhideFeed', {feedId: action.id})
+ break
+ }
+ case 'toggleHideAllFeeds': {
+ if (updated?.hideAllFeeds) {
+ logger.metric('liveEventFeed:hideAllFeeds', {})
+ } else {
+ logger.metric('liveEventFeed:unhideAllFeeds', {})
+ }
+ break
+ }
+ }
+
+ // triggers a refetch
+ await queryClient.invalidateQueries({
+ queryKey: preferencesQueryKey,
+ })
+ },
+ })
+}
diff --git a/src/features/liveEvents/types.ts b/src/features/liveEvents/types.ts
index 3ab2ff73bf..a2070e1c03 100644
--- a/src/features/liveEvents/types.ts
+++ b/src/features/liveEvents/types.ts
@@ -9,6 +9,7 @@ export type LiveEventFeedImage = {
}
export type LiveEventFeed = {
+ id: string
title: string
url: string
images: Record
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index dddca72a17..78988ab857 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -788,4 +788,9 @@ export type MetricEvents = {
}
// user pressed the remove all data button
'contacts:settings:removeData': {}
+
+ 'liveEventFeeds:hideFeed': {feedId: string}
+ 'liveEventFeeds:unhideFeed': {feedId: string}
+ 'liveEventFeed:hideAllFeeds': {}
+ 'liveEventFeed:unhideAllFeeds': {}
}
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index 9bd8aa923b..d1e4a8d88c 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -50,8 +50,6 @@ export type Device = {
activitySubscriptionsNudged?: boolean
threadgateNudged?: boolean
- liveEventFeedsBannerDisabled?: boolean
-
/**
* Policy update overlays. New IDs are required for each new announcement.
*/