Add prefs methods, hook up to Discover banner

This commit is contained in:
Eric Bailey
2026-01-13 20:55:33 -06:00
parent 9c1eb63620
commit f8e5f35c32
7 changed files with 279 additions and 24 deletions
+1
View File
@@ -35,6 +35,7 @@ module.exports = {
'Admonition',
'Admonition.Admonition',
'Toast.Action',
'toast.Action',
'AgeAssuranceAdmonition',
'Span',
'StackedButton',
@@ -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 <TrendingInterstitial />
@@ -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() {
},
]}
/>
<CloseIcon
size="xs"
<EllipsisIcon
size="sm"
fill={
image.textColor === 'light'
? t.palette.white
@@ -82,17 +88,7 @@ export function DiscoverFeedLiveEventFeedsAndTrendingBanner() {
</View>
</View>
<Prompt.Basic
control={promptControl}
title={_(msg`Disable the live event banner?`)}
description={_(
msg`Live events appear occasionally when something exciting is happening that we think you might like. You can always re-enable this banner from your Content & Media settings page.`,
)}
confirmButtonCta={_(msg`Disable banner`)}
onConfirm={() => {
setBannerDisabled(true)
}}
/>
<LiveEventFeedOptionsMenu feed={feed} control={optionsMenuControl} />
</>
)
}
@@ -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 (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Configure live event banner`)}
style={[web({maxWidth: 400})]}>
<Inner control={control} feed={feed} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Inner({
control,
feed,
}: {
control: Dialog.DialogControlProps
feed: LiveEventFeed
}) {
const {_} = useLingui()
const canUndo = useRef(true)
const undoAction = useRef<LiveEventPreferencesAction | null>(null)
const {
isPending,
mutate: update,
error: rawError,
variables,
} = useUpdateLiveEventPreferences({
onSuccess() {
toast.show(
<toast.Outer>
<toast.Icon />
<toast.Text>
<Trans>Your live event preferences have been updated.</Trans>
</toast.Text>
{canUndo.current && undoAction.current && (
<toast.Action
label={_(msg`Undo`)}
onPress={() => {
if (undoAction.current) {
update(undoAction.current)
}
}}>
<Trans>Undo</Trans>
</toast.Action>
)}
</toast.Outer>,
{
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 (
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_semi_bold, a.leading_snug]}>
<Trans>Live event options</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Live events appear occasionally when something exciting is
happening. If you'd like, you can hide this particular event, or all
events.
</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
If you choose to hide all events, you can always re-enable them from{' '}
<Span style={[a.font_semi_bold]}>Settings → Content & Media</Span>.
</Trans>
</Text>
</View>
<View style={[a.gap_sm]}>
<Button
label={_(msg`Hide this event`)}
size="large"
color="primary_subtle"
onPress={() => {
update({type: 'hideFeed', id: feed.id})
undoAction.current = {type: 'unhideFeed', id: feed.id}
}}>
<ButtonText>
<Trans>Hide this event</Trans>
</ButtonText>
{isHidingFeed && <ButtonIcon icon={Loader} />}
</Button>
<Button
label={_(msg`Hide all events`)}
size="large"
color="secondary"
onPress={() => {
update({type: 'toggleHideAllFeeds'})
undoAction.current = {type: 'toggleHideAllFeeds'}
}}>
<ButtonText>
<Trans>Hide all events</Trans>
</ButtonText>
{isHidingAllFeeds && <ButtonIcon icon={Loader} />}
</Button>
{isNative && (
<Button
label={_(msg`Cancel`)}
size="large"
color="secondary_inverted"
onPress={() => control.close()}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
)}
</View>
{error && (
<Admonition type="error">
{error.clean || error.raw || _(msg`An unknown error occurred.`)}
</Admonition>
)}
</View>
)
}
+87
View File
@@ -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<void, Error, LiveEventPreferencesAction>({
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,
})
},
})
}
+1
View File
@@ -9,6 +9,7 @@ export type LiveEventFeedImage = {
}
export type LiveEventFeed = {
id: string
title: string
url: string
images: Record<LiveEventFeedImageLayout, LiveEventFeedImage>
+5
View File
@@ -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': {}
}
-2
View File
@@ -50,8 +50,6 @@ export type Device = {
activitySubscriptionsNudged?: boolean
threadgateNudged?: boolean
liveEventFeedsBannerDisabled?: boolean
/**
* Policy update overlays. New IDs are required for each new announcement.
*/