diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index b99cb961e..966dcfd7f 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -138,6 +138,8 @@ "exclude_lure": "Exclude Lure", "timer": "Timer", "hide": "Hide", + "hidden_for_hour": "Hidden for an hour", + "clean_hidden": "Clean Hidden", "tier": "Tier", "slots": "Slots", "mega": "Mega", diff --git a/packages/locales/lib/human/pl.json b/packages/locales/lib/human/pl.json index 077c4b17b..cf3310edf 100644 --- a/packages/locales/lib/human/pl.json +++ b/packages/locales/lib/human/pl.json @@ -228,6 +228,8 @@ "has_quest_indicator": "Alternatywny kolor dla Pokéstopów z zadaniami", "help": "Pomoc", "hide": "Ukryj", + "hidden_for_hour": "Schowano na godzinę", + "clean_hidden": "Wyczyść schowane", "hide_editor": "Ukryj edytor", "historic_rarity": "Rzadkość historyczna", "hisuian": "Hisuian", diff --git a/src/features/drawer/settings/index.jsx b/src/features/drawer/settings/index.jsx index 69254ecb8..d07f2eba6 100644 --- a/src/features/drawer/settings/index.jsx +++ b/src/features/drawer/settings/index.jsx @@ -9,6 +9,7 @@ import InsightsIcon from '@mui/icons-material/Insights' import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive' import NotificationsOffIcon from '@mui/icons-material/NotificationsOff' import LogoDevIcon from '@mui/icons-material/LogoDev' +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff' import { useTranslation } from 'react-i18next' import { useMemory } from '@store/useMemory' @@ -22,6 +23,7 @@ import { LocaleSelection } from '@components/inputs/LocaleSelection' import { DividerWithMargin } from '@components/StyledDivider' import { BoolToggle } from '@components/inputs/BoolToggle' import { BasicListButton } from '@components/inputs/BasicListButton' +import { clearHiddenEntities } from '@utils/hiddenEntities' import { DrawerActions } from '../components/Actions' import { GeneralSetting } from './General' @@ -70,6 +72,15 @@ export function Settings() { )} + { + clearHiddenEntities() + useMemory.setState({ hideList: new Set() }) + }} + label="clean_hidden" + > + + diff --git a/src/features/gym/GymPopup.jsx b/src/features/gym/GymPopup.jsx index a05b62b33..d7905ba56 100644 --- a/src/features/gym/GymPopup.jsx +++ b/src/features/gym/GymPopup.jsx @@ -37,6 +37,7 @@ import { getTimeUntil } from '@utils/getTimeUntil' import { formatInterval } from '@utils/formatInterval' import { usePokemonBackgroundVisuals } from '@hooks/usePokemonBackgroundVisuals' import { getFormDisplay } from '@utils/getFormDisplay' +import { addHiddenEntity, showHideSnackbar } from '@utils/hiddenEntities' import { useWebhook } from './useWebhook' @@ -747,7 +748,8 @@ const DropdownOptions = ({ const handleHide = () => { handleClose() - useMemory.setState((prev) => ({ hideList: new Set(prev.hideList).add(id) })) + useMemory.setState({ hideList: addHiddenEntity(id) }) + showHideSnackbar(t('hidden_for_hour')) } const handleExclude = (key) => { diff --git a/src/features/nest/NestPopup.jsx b/src/features/nest/NestPopup.jsx index 35c55c377..9df705834 100644 --- a/src/features/nest/NestPopup.jsx +++ b/src/features/nest/NestPopup.jsx @@ -16,6 +16,7 @@ import { setDeepStore } from '@store/useStorage' import { getTimeUntil } from '@utils/getTimeUntil' import { useAnalytics } from '@hooks/useAnalytics' import { Navigation } from '@components/popups/Navigation' +import { addHiddenEntity, showHideSnackbar } from '@utils/hiddenEntities' /** @param {number} timeSince */ const getColor = (timeSince) => { @@ -62,7 +63,8 @@ export function NestPopup({ const handleClose = () => setAnchorEl(null) const handleHide = () => { setAnchorEl(null) - useMemory.setState((prev) => ({ hideList: new Set(prev.hideList).add(id) })) + useMemory.setState({ hideList: addHiddenEntity(id) }) + showHideSnackbar(t('hidden_for_hour')) } const handleExclude = () => { diff --git a/src/features/pokemon/PokemonPopup.jsx b/src/features/pokemon/PokemonPopup.jsx index f88937713..3fc5dbf2b 100644 --- a/src/features/pokemon/PokemonPopup.jsx +++ b/src/features/pokemon/PokemonPopup.jsx @@ -33,6 +33,7 @@ import { GET_TAPPABLE_BY_ID } from '@services/queries/tappable' import { usePokemonBackgroundVisual } from '@hooks/usePokemonBackgroundVisuals' import { BackgroundCard } from '@components/popups/BackgroundCard' import { getFormDisplay } from '@utils/getFormDisplay' +import { addHiddenEntity } from '@utils/hiddenEntities' const rowClass = { width: 30, fontWeight: 'bold' } @@ -357,7 +358,7 @@ const Header = ({ pokemon, metaData, iconUrl, userSettings, isTutorial }) => { const handleHide = () => { setAnchorEl(null) - useMemory.setState((prev) => ({ hideList: new Set(prev.hideList).add(id) })) + useMemory.setState({ hideList: addHiddenEntity(id) }) } const handleExclude = () => { diff --git a/src/features/pokestop/PokestopPopup.jsx b/src/features/pokestop/PokestopPopup.jsx index 14a9fbde9..772356add 100644 --- a/src/features/pokestop/PokestopPopup.jsx +++ b/src/features/pokestop/PokestopPopup.jsx @@ -35,6 +35,7 @@ import { useGetAvailable } from '@hooks/useGetAvailable' import { parseQuestConditions } from '@utils/parseConditions' import { Img } from '@components/Img' import { readableProbability } from '@utils/readableProbability' +import { addHiddenEntity, showHideSnackbar } from '@utils/hiddenEntities' import { usePokemonBackgroundVisuals, usePokemonBackgroundVisual, @@ -341,7 +342,8 @@ const MenuActions = ({ const handleHide = () => { setAnchorEl(null) - useMemory.setState((prev) => ({ hideList: new Set(prev.hideList).add(id) })) + useMemory.setState({ hideList: addHiddenEntity(id) }) + showHideSnackbar(t('hidden_for_hour')) } /** @param {string} key */ diff --git a/src/features/station/StationPopup.jsx b/src/features/station/StationPopup.jsx index 5b13d60ed..05eb990f7 100644 --- a/src/features/station/StationPopup.jsx +++ b/src/features/station/StationPopup.jsx @@ -27,6 +27,7 @@ import { Img, PokemonImg } from '@components/Img' import { useFormatStore } from '@store/useFormatStore' import { useRelativeTimer } from '@hooks/useRelativeTime' import { useAnalytics } from '@hooks/useAnalytics' +import { addHiddenEntity, showHideSnackbar } from '@utils/hiddenEntities' import { BackgroundCard } from '@components/popups/BackgroundCard' import { Title } from '@components/popups/Title' import { @@ -169,10 +170,10 @@ function StationMenu({ () => [ { name: 'hide', - action: () => - useMemory.setState((prev) => ({ - hideList: new Set(prev.hideList).add(id), - })), + action: () => { + useMemory.setState({ hideList: addHiddenEntity(id) }) + showHideSnackbar(t('hidden_for_hour')) + }, }, { name: 'exclude_battle', diff --git a/src/features/tappable/TappablePopup.jsx b/src/features/tappable/TappablePopup.jsx index 0da263d43..0be0c0b0f 100644 --- a/src/features/tappable/TappablePopup.jsx +++ b/src/features/tappable/TappablePopup.jsx @@ -20,6 +20,8 @@ import { StatusIcon } from '@components/StatusIcon' import { Title } from '@components/popups/Title' import { getTimeUntil } from '@utils/getTimeUntil' +import { addHiddenEntity, showHideSnackbar } from '@utils/hiddenEntities' + import { getTappableDisplaySettings } from './displayRules' /** @@ -107,10 +109,9 @@ export function TappablePopup({ tappable, rewardIcon }) { const handleHide = React.useCallback(() => { setMenuAnchorEl(null) if (tappable.id === undefined || tappable.id === null) return - useMemory.setState((prev) => ({ - hideList: new Set(prev.hideList).add(tappable.id), - })) - }, [tappable.id]) + useMemory.setState({ hideList: addHiddenEntity(tappable.id) }) + showHideSnackbar(t('hidden_for_hour')) + }, [tappable.id, t]) const handleExclude = React.useCallback(() => { setMenuAnchorEl(null) diff --git a/src/pages/map/components/Container.jsx b/src/pages/map/components/Container.jsx index ffacef9a8..95c1cd0b5 100644 --- a/src/pages/map/components/Container.jsx +++ b/src/pages/map/components/Container.jsx @@ -9,6 +9,7 @@ import { ScanOnDemand } from '@features/scanner' import { WebhookMarker, WebhookAreaSelection } from '@features/webhooks' import { ActiveWeather } from '@features/weather' import { timeCheck } from '@utils/timeCheck' +import { cleanupHiddenEntities } from '@utils/hiddenEntities' import { Effects } from './Effects' import { DataView } from './Data' @@ -38,6 +39,15 @@ const MAX_BOUNDS = /** @type {[[number, number], [number, number]]} */ ([ export function Container() { const { location, zoom } = useStorage.getState() + // Cleanup hidden entities every 15 seconds + React.useEffect(() => { + const interval = setInterval( + () => cleanupHiddenEntities(useMemory.setState), + 15000, + ) + return () => clearInterval(interval) + }, []) + return ( ({ locationCards: {}, routeTypes: {}, }, - hideList: new Set(), + hideList: getHiddenEntitySet(), timerList: [], timeOfDay: 'day', extraUserFields: [], diff --git a/src/utils/hiddenEntities.js b/src/utils/hiddenEntities.js new file mode 100644 index 000000000..c9622cdf5 --- /dev/null +++ b/src/utils/hiddenEntities.js @@ -0,0 +1,168 @@ +// @ts-check + +const STORAGE_KEY = 'hidden-entities' +const SNACKBAR_COUNT_KEY = 'hidden-entities-snackbar-count' +const MAX_AGE_MS = 60 * 60 * 1000 // 1 hour +const MAX_SNACKBAR_SHOWS = 3 + +/** + * @typedef {{ id: string | number, ts: number }} HiddenEntry + */ + +/** + * Load hidden entries from localStorage + * @returns {HiddenEntry[]} + */ +function loadEntries() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +/** + * Save hidden entries to localStorage + * @param {HiddenEntry[]} entries + */ +function saveEntries(entries) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)) + } catch { + // localStorage may be full or disabled + } +} + +/** + * Clean entries older than 1 hour + * @param {HiddenEntry[]} entries + * @returns {HiddenEntry[]} + */ +function cleanOldEntries(entries) { + const now = Date.now() + return entries.filter((e) => now - e.ts < MAX_AGE_MS) +} + +/** + * Add an entity ID to the hidden list with timestamp + * @param {string | number} id + * @returns {Set} Updated hideList Set + */ +export function addHiddenEntity(id) { + const entries = loadEntries() + if (!entries.some((e) => e.id === id)) { + entries.push({ id, ts: Date.now() }) + } + saveEntries(entries) + return new Set(entries.map((e) => e.id)) +} + +/** + * Get the current hidden entity Set from localStorage + * @returns {Set} + */ +export function getHiddenEntitySet() { + const entries = loadEntries() + return new Set(entries.map((e) => e.id)) +} + +/** + * Clean outdated hidden entries (older than 1 hour) from localStorage + * Updates both localStorage and the in-memory hideList + * @param {(state: { hideList: Set }) => void} setState + */ +export function cleanupHiddenEntities(setState) { + const entries = loadEntries() + const cleaned = cleanOldEntries(entries) + if (cleaned.length !== entries.length) { + saveEntries(cleaned) + setState({ hideList: new Set(cleaned.map((e) => e.id)) }) + } +} + +/** @type {{ current: number | null }} */ +const snackbarTimer = { current: null } + +/** @type {{ current: HTMLDivElement | null }} */ +const snackbarRef = { current: null } + +/** + * Get snackbar show count from localStorage + * @returns {number} + */ +function getSnackbarCount() { + try { + return parseInt(localStorage.getItem(SNACKBAR_COUNT_KEY) || '0', 10) + } catch { + return 0 + } +} + +/** + * Increment snackbar show count in localStorage + */ +function incrementSnackbarCount() { + try { + const count = getSnackbarCount() + 1 + localStorage.setItem(SNACKBAR_COUNT_KEY, String(count)) + } catch { + // localStorage may be full or disabled + } +} + +/** + * Show a temporary snackbar message for 2 seconds (max 3 times total) + * @param {string} message + */ +export function showHideSnackbar(message) { + if (getSnackbarCount() >= MAX_SNACKBAR_SHOWS) { + return + } + + if (snackbarTimer.current) { + clearTimeout(snackbarTimer.current) + } + if (snackbarRef.current) { + snackbarRef.current.remove() + } + + incrementSnackbarCount() + + const snackbar = document.createElement('div') + snackbar.textContent = message + snackbar.style.cssText = ` + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: rgba(50, 50, 50, 0.95); + color: white; + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + z-index: 10000; + pointer-events: none; + ` + document.body.appendChild(snackbar) + snackbarRef.current = snackbar + + snackbarTimer.current = window.setTimeout(() => { + snackbar.remove() + snackbarRef.current = null + snackbarTimer.current = null + }, 2000) +} + +/** + * Clear all hidden entities from localStorage + */ +export function clearHiddenEntities() { + try { + localStorage.removeItem(STORAGE_KEY) + } catch { + // localStorage may be disabled + } +}