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. */