diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9cb6a54b2..0a5bf9cfd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: run: yarn masterfile - name: Lint - run: yarn eslint + run: yarn lint - name: Prettier run: yarn prettier diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bba06caa2..6b04713a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,8 @@ jobs: run: yarn --prefer-offline env: HUSKY: 0 + - name: Lint app + run: yarn lint - name: Build app run: yarn build - name: Release diff --git a/.gitignore b/.gitignore index dbbc19e20..ce91cb8c7 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,7 @@ server/src/models/queries/* # Cache server/.cache/* !/server/.cache/.gitkeep +.eslintcache # Misc ts-check.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 230f0d171..0f0065280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,41 @@ +## [1.41.2-develop.5](https://github.com/WatWowMap/ReactMap/compare/v1.41.2-develop.4...v1.41.2-develop.5) (2026-04-19) + + +### Bug Fixes + +* perms cleanup ([c513936](https://github.com/WatWowMap/ReactMap/commit/c513936fe84f5c863644fe90555de73c723d7a44)) + +## [1.41.2-develop.4](https://github.com/WatWowMap/ReactMap/compare/v1.41.2-develop.3...v1.41.2-develop.4) (2026-04-04) + + +### Bug Fixes + +* restore drawer sublist scroll positions ([8950dd5](https://github.com/WatWowMap/ReactMap/commit/8950dd56745be7ee591ccfffe6e97b6cfafb34fc)) + +## [1.41.2-develop.3](https://github.com/WatWowMap/ReactMap/compare/v1.41.2-develop.2...v1.41.2-develop.3) (2026-04-03) + + +### Bug Fixes + +* weather update consistency ([184ac2f](https://github.com/WatWowMap/ReactMap/commit/184ac2fa3fbc372d896f70bf36277fa1dd2e6a22)) + +## [1.41.2-develop.2](https://github.com/WatWowMap/ReactMap/compare/v1.41.2-develop.1...v1.41.2-develop.2) (2026-04-03) + + +### Bug Fixes + +* keep drawer scroll position ([a242e84](https://github.com/WatWowMap/ReactMap/commit/a242e842acaf9f5526491c7a294b8a677979b48f)) +* properly set default discord auth as none ([3670810](https://github.com/WatWowMap/ReactMap/commit/3670810193e1c74bcd8352825083c069b33b1bff)) + +## [1.41.2-develop.1](https://github.com/WatWowMap/ReactMap/compare/v1.41.1...v1.41.2-develop.1) (2026-03-26) + + +### Bug Fixes + +* change scan-on-demand dialog to notification ([b77145d](https://github.com/WatWowMap/ReactMap/commit/b77145dd8e037f0916d72c6a84e76fbb9584bde8)) +* **pokestops:** align incident blocker visibility across markers and popups ([8acf245](https://github.com/WatWowMap/ReactMap/commit/8acf245983402e9e14ef78f628fae217c1e23000)) +* weather icon uses wrong time of day for wild ([5944452](https://github.com/WatWowMap/ReactMap/commit/594445268ca027b8f2b1d9c8edce70208b23c49d)) + ## [1.41.1](https://github.com/WatWowMap/ReactMap/compare/v1.41.0...v1.41.1) (2026-03-15) @@ -5,6 +43,13 @@ * workflow git creds ([e5dc6f9](https://github.com/WatWowMap/ReactMap/commit/e5dc6f90d14fa1afb8a75bb9bdb2246213ff1c88)) +# [1.41.0-develop.12](https://github.com/WatWowMap/ReactMap/compare/v1.41.0-develop.11...v1.41.0-develop.12) (2026-03-15) + + +### Bug Fixes + +* weather icon uses wrong time of day for wild ([5944452](https://github.com/WatWowMap/ReactMap/commit/594445268ca027b8f2b1d9c8edce70208b23c49d)) + # [1.41.0](https://github.com/WatWowMap/ReactMap/compare/v1.40.1...v1.41.0) (2026-03-15) diff --git a/config/local.example.json b/config/local.example.json index 5f93a1674..45ca6d0eb 100644 --- a/config/local.example.json +++ b/config/local.example.json @@ -84,7 +84,8 @@ "redirectUri": "http://localhost:8080/auth/discord/callback", "allowedGuilds": [], "blockedGuilds": [], - "allowedUsers": [] + "allowedUsers": [], + "clientPrompt": "none" } ], "areaRestrictions": [ diff --git a/config/multi-domain-example/local.json b/config/multi-domain-example/local.json index ff737d99b..c2310c10e 100644 --- a/config/multi-domain-example/local.json +++ b/config/multi-domain-example/local.json @@ -82,7 +82,8 @@ "redirectUri": "http://localhost:8080/auth/discord/callback", "allowedGuilds": [], "blockedGuilds": [], - "allowedUsers": [] + "allowedUsers": [], + "clientPrompt": "none" } ], "alwaysEnabledPerms": ["map"], diff --git a/package.json b/package.json index 0c4bac6ff..d7b384e9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.41.1", + "version": "1.41.2-develop.5", "private": true, "description": "React based frontend map.", "license": "MIT", @@ -47,7 +47,7 @@ }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ - "eslint \"**/*.{js,jsx}\" --fix" + "eslint --cache --fix" ], "**/*": [ "prettier --write --ignore-unknown" @@ -99,7 +99,7 @@ }, "dependencies": { "@apollo/client": "3.11.4", - "@apollo/server": "5.4.0", + "@apollo/server": "5.5.0", "@as-integrations/express5": "1.1.2", "@emotion/react": "11.14.0", "@emotion/styled": "11.13.0", @@ -211,7 +211,7 @@ "rollup-plugin-delete": "^2.0.0", "semantic-release": "^22", "typescript": "5.5.4", - "vite": "^6.4.1", + "vite": "^6.4.2", "vite-plugin-checker": "0.7.2" }, "resolutions": { diff --git a/packages/config/lib/mutations.js b/packages/config/lib/mutations.js index aad7c2c08..58b0e7835 100644 --- a/packages/config/lib/mutations.js +++ b/packages/config/lib/mutations.js @@ -315,6 +315,9 @@ const applyMutations = (config) => { config.authentication.strategies = config.authentication.strategies.map( (strategy) => ({ ...strategy, + ...(strategy.type === 'discord' + ? { clientPrompt: strategy.clientPrompt ?? 'none' } + : {}), allowedGuilds: Array.isArray(strategy.allowedGuilds) ? strategy.allowedGuilds.flatMap(replaceAliases) : [], diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index b44f66bee..a4ff952f1 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -623,6 +623,7 @@ "show_size_indicator": "Show Size Indicator", "size": "Size", "gold_stop": "Gold Stop", + "gold_stop_block": "Blocked due to a gold stop", "profile_backups": "Profile Swapping", "new_backup": "New Backup", "create": "Create", diff --git a/packages/types/lib/augmentations.d.ts b/packages/types/lib/augmentations.d.ts index 43f40a704..0d6ac029c 100644 --- a/packages/types/lib/augmentations.d.ts +++ b/packages/types/lib/augmentations.d.ts @@ -32,6 +32,7 @@ declare global { declare module 'express-session' { interface SessionData { tutorial: boolean + discordPromptRetry?: string } } diff --git a/packages/types/lib/scanner.d.ts b/packages/types/lib/scanner.d.ts index 6cce54a32..46a0d50e7 100644 --- a/packages/types/lib/scanner.d.ts +++ b/packages/types/lib/scanner.d.ts @@ -214,7 +214,8 @@ export interface Pokestop { showcase_pokemon_id?: number showcase_ranking_standard?: number showcase_rankings?: ShowcaseDetails | string - hasShowcase: boolean + incident_blocker_display_type: number | null + incident_blocker_expire_timestamp: number | null } export type FullPokestop = FullModel diff --git a/packages/vite-plugins/package.json b/packages/vite-plugins/package.json index 81a87e051..0ef65f5e8 100644 --- a/packages/vite-plugins/package.json +++ b/packages/vite-plugins/package.json @@ -16,6 +16,6 @@ "@rm/logger": "*" }, "devDependencies": { - "vite": "^6.4.1" + "vite": "^6.4.2" } } diff --git a/server/src/graphql/typeDefs/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index 12bc0efa2..e7bbecf52 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -147,7 +147,9 @@ type Pokestop { power_up_level: Int power_up_points: Int power_up_end_timestamp: Int - hasShowcase: Boolean + showcase_expiry: Int + incident_blocker_display_type: Int + incident_blocker_expire_timestamp: Int } type PokemonShinyStats { diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 25e12d84a..cd4e5c5c5 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -720,6 +720,41 @@ class Pokestop extends Model { fields.forEach((field) => (target[field] = source[field])) } + static getIncidentDisplayType(incident, isMad, hasMultiInvasions) { + return isMad && !hasMultiInvasions + ? MAD_GRUNT_MAP[incident.grunt_type] || 8 + : incident.display_type + } + + static getIncidentBlocker(incidents, isMad, hasMultiInvasions) { + const blocker = { + displayType: 0, + expireTimestamp: 0, + } + + ;(incidents || []).forEach((incident) => { + const displayType = this.getIncidentDisplayType( + incident, + isMad, + hasMultiInvasions, + ) + // Showcase expiry is tracked separately on the client so local timer + // updates can fall through to the next hidden blocker without a refetch. + if ( + displayType === 7 && + incident.incident_expire_timestamp > blocker.expireTimestamp + ) { + blocker.displayType = 7 + blocker.expireTimestamp = incident.incident_expire_timestamp + } + }) + + return { + displayType: blocker.displayType || null, + expireTimestamp: blocker.expireTimestamp || null, + } + } + // filters and removes unwanted data static secondaryFilter( queryResults, @@ -734,7 +769,18 @@ class Pokestop extends Model { const filteredResults = [] for (let i = 0; i < queryResults.length; i += 1) { const pokestop = queryResults[i] - const filtered = { hasShowcase: pokestop.showcase_expiry > ts } + const canViewIncidentMetadata = perms.eventStops || perms.invasions + const incidentBlocker = canViewIncidentMetadata + ? this.getIncidentBlocker(pokestop.invasions, isMad, hasMultiInvasions) + : null + const filtered = { + showcase_expiry: canViewIncidentMetadata + ? pokestop.showcase_expiry || null + : null, + incident_blocker_display_type: incidentBlocker?.displayType || null, + incident_blocker_expire_timestamp: + incidentBlocker?.expireTimestamp || null, + } this.fieldAssigner(filtered, pokestop, [ 'id', @@ -790,10 +836,11 @@ class Pokestop extends Model { event.display_type === 9 ? pokestop.showcase_ranking_standard : null, - display_type: - isMad && !hasMultiInvasions - ? MAD_GRUNT_MAP[event.grunt_type] || 8 - : event.display_type, + display_type: this.getIncidentDisplayType( + event, + isMad, + hasMultiInvasions, + ), })) .filter((event) => event.showcase_pokemon_id diff --git a/server/src/routes/authRouter.js b/server/src/routes/authRouter.js index 34cd61a0c..71b0a391e 100644 --- a/server/src/routes/authRouter.js +++ b/server/src/routes/authRouter.js @@ -18,22 +18,61 @@ const loadAuthStrategies = () => { : 'post' if (strategy.enabled) { const name = strategy.name ?? `${strategy.type}-${i}` - const callbackOptions = {} - const authenticateOptions = { - failureRedirect: '/', - successRedirect: '/', - } - if (strategy.type === 'discord') { - callbackOptions.prompt = strategy.clientPrompt + const isDiscordPromptRetry = (req) => + strategy.type === 'discord' && req.session.discordPromptRetry === name + const getAuthenticateOptions = (req, includeRedirects = false) => { + const options = includeRedirects + ? { + failureRedirect: '/', + successRedirect: '/', + } + : {} + + if ( + strategy.type === 'discord' && + strategy.clientPrompt && + !isDiscordPromptRetry(req) + ) { + options.prompt = strategy.clientPrompt + } + + return options } - authRouter[method]( - `/${name}`, - passport.authenticate(name, authenticateOptions), + + authRouter[method](`/${name}`, (req, res, next) => + passport.authenticate(name, getAuthenticateOptions(req, true))( + req, + res, + next, + ), ) - authRouter[method](`/${name}/callback`, async (req, res, next) => - passport.authenticate( + authRouter[method](`/${name}/callback`, async (req, res, next) => { + if ( + strategy.type === 'discord' && + strategy.clientPrompt === 'none' && + !isDiscordPromptRetry(req) && + typeof req.query.error === 'string' + ) { + req.session.discordPromptRetry = name + log.debug( + TAGS.auth, + 'Discord silent auth needs user interaction, retrying with approval page', + ) + return res.redirect(`${req.baseUrl}/${name}/callback`) + } + + if ( + strategy.type === 'discord' && + isDiscordPromptRetry(req) && + (typeof req.query.code === 'string' || + typeof req.query.error === 'string') + ) { + delete req.session.discordPromptRetry + } + + return passport.authenticate( name, - callbackOptions, + getAuthenticateOptions(req), async (err, user, info) => { if (err) { return next(err) @@ -67,8 +106,8 @@ const loadAuthStrategies = () => { } } }, - )(req, res, next), - ) + )(req, res, next) + }) log.info( TAGS.auth, `${method.toUpperCase()} /auth/${name}/callback route initialized`, diff --git a/server/src/ui/drawer.js b/server/src/ui/drawer.js index 3af7527df..a9491f44c 100644 --- a/server/src/ui/drawer.js +++ b/server/src/ui/drawer.js @@ -52,14 +52,14 @@ function drawer(req, perms) { } : BLOCKED, pokestops: - (perms.pokestops || perms.lures || perms.quests || perms.invasions) && + (perms.pokestops || perms.quests || perms.invasions || perms.lures) && state.db.models.Pokestop ? { allPokestops: perms.pokestops || BLOCKED, - lures: perms.lures || BLOCKED, - eventStops: perms.eventStops || BLOCKED, quests: perms.quests || BLOCKED, invasions: perms.invasions || BLOCKED, + eventStops: perms.eventStops || BLOCKED, + lures: perms.lures || BLOCKED, arEligible: perms.pokestops || BLOCKED, } : BLOCKED, diff --git a/src/components/Notification.jsx b/src/components/Notification.jsx index 9afd2190d..7c6689881 100644 --- a/src/components/Notification.jsx +++ b/src/components/Notification.jsx @@ -14,6 +14,7 @@ function SlideTransition(props) { /** @type {React.CSSProperties} */ const alertStyle = { textAlign: 'center', color: 'white' } +const DEFAULT_AUTO_HIDE_DURATION = 5000 /** * @@ -26,6 +27,9 @@ const alertStyle = { textAlign: 'center', color: 'white' } * children?: T extends string ? never : React.ReactNode * cb?: () => void * title?: string + * autoHideDuration?: number | null + * ignoreClickaway?: boolean + * closable?: boolean * }} props * @returns */ @@ -37,34 +41,47 @@ export function Notification({ children, cb, title, + autoHideDuration = DEFAULT_AUTO_HIDE_DURATION, + ignoreClickaway = false, + closable = true, }) { const { t } = useTranslation() - const [alert, setAlert] = React.useState(open || false) + const [alert, setAlert] = React.useState(!!open) const handleClose = React.useCallback(() => { setAlert(false) if (cb) cb() }, [cb]) + const handleSnackbarClose = React.useCallback( + (_, reason) => { + if (reason === 'clickaway' && ignoreClickaway) return + if (!closable) return + handleClose() + }, + [closable, handleClose, ignoreClickaway], + ) + React.useEffect(() => { - setAlert(open) + setAlert(!!open) - if (open) { + if (open && typeof autoHideDuration === 'number') { const timer = setTimeout(() => { handleClose() - }, 5000) + }, autoHideDuration) return () => clearTimeout(timer) } - }, [open]) + return undefined + }, [autoHideDuration, handleClose, open]) return ( ( * Header?: React.ComponentType, * Footer?: React.ComponentType, * useWindowScroll?: boolean, - * scrollerRef?: import('react-virtuoso').VirtuosoGridProps['scrollerRef'] + * scrollerRef?: import('react-virtuoso').VirtuosoGridProps['scrollerRef'], + * restoreStateFrom?: import('react-virtuoso').VirtuosoGridProps['restoreStateFrom'], + * stateChanged?: import('react-virtuoso').VirtuosoGridProps['stateChanged'], * }} props */ export function VirtualGrid({ @@ -57,6 +59,8 @@ export function VirtualGrid({ Footer, useWindowScroll, scrollerRef, + restoreStateFrom, + stateChanged, }) { const fullContext = React.useMemo( () => ({ ...context, xs, sm, md, lg, xl }), @@ -77,6 +81,8 @@ export function VirtualGrid({ itemContent={children} useWindowScroll={useWindowScroll} scrollerRef={scrollerRef} + restoreStateFrom={restoreStateFrom} + stateChanged={stateChanged} /> ) } diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index ffdfcf83d..f72f5187c 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -18,6 +18,8 @@ import { useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { useMapStore } from '@store/useMapStore' +import { useDrawerScrollMemory } from '../hooks/useScrollMemory' + /** @typedef {{ id: string, name: string, lat: number, lon: number }} JumpResult */ import { AreaParent } from './Parent' @@ -32,6 +34,7 @@ export function ScanAreasTable() { const trimmedSearch = React.useMemo(() => rawSearch.trim(), [rawSearch]) const { misc, general } = useMemory.getState().config const jumpZoom = general?.scanAreasZoom || general?.startZoom || 12 + const tableScrollMemory = useDrawerScrollMemory('scanAreas:table') /** @type {[JumpResult[], React.Dispatch>]} */ const [jumpResults, setJumpResults] = React.useState([]) const [jumpLoading, setJumpLoading] = React.useState(false) @@ -161,6 +164,7 @@ export function ScanAreasTable() { return ( } props * @returns */ -function SelectorList({ category, subCategory, label, height = 400 }) { +function SelectorList({ + category, + subCategory, + label, + height = 400, + scrollKey, + visible = true, +}) { const searchKey = `${category}${ subCategory ? capitalize(subCategory) : '' }QuickSelect` + const listScrollKey = + scrollKey || + `selector:${category}:${subCategory || 'default'}:${label || 'default'}` + const drawer = useLayoutStore((s) => s.drawer) const { available } = useGetAvailable(category) const { t: tId } = useTranslateById({ quest: subCategory === 'pokemon', @@ -114,6 +133,32 @@ function SelectorList({ category, subCategory, label, height = 400 }) { .map((item) => item.id) }, [translated, search]) + const restoreStateFrom = React.useMemo( + () => getDrawerGridState(listScrollKey), + [listScrollKey], + ) + const shouldPersistGridState = drawer && visible + const scrollMemory = useDrawerScrollMemory( + listScrollKey, + shouldPersistGridState, + ) + + const handleStateChanged = React.useCallback( + (state) => { + if ( + !shouldPersistGridState || + !state.viewport.height || + !state.viewport.width || + !state.item.height || + !state.item.width + ) { + return + } + setDrawerGridState(listScrollKey, state) + }, + [listScrollKey, shouldPersistGridState], + ) + /** @param {'enable' | 'disable' | 'advanced'} action */ const setAll = (action) => { const keys = new Set(items.map((item) => item)) @@ -194,7 +239,13 @@ function SelectorList({ category, subCategory, label, height = 400 }) { : height } > - + {(_, key) => } @@ -208,13 +259,16 @@ export const SelectorListMemo = React.memo( prev.category === next.category && prev.subCategory === next.subCategory && prev.label === next.label && - prev.height === next.height, + prev.height === next.height && + prev.scrollKey === next.scrollKey && + prev.visible === next.visible, ) /** @param {{ children: React.ReactElement[], tabKey: string }} props */ export function MultiSelectorList({ children, tabKey }) { const { t } = useTranslation() const [openTab, setOpenTab] = useDeepStore(`tabs.${tabKey}`, 0) + const visibleChildren = children.filter(Boolean) /** @type {import('@mui/material').TabsProps['onChange']} */ const handleTabChange = React.useCallback( @@ -226,14 +280,14 @@ export function MultiSelectorList({ children, tabKey }) { - {children.map((child) => ( + {visibleChildren.map((child) => ( ))} - {children.filter(Boolean).map((child, index) => ( + {visibleChildren.map((child, index) => ( - {child} + {React.cloneElement(child, { visible: openTab === index })} ))} diff --git a/src/features/drawer/hooks/useScrollMemory.js b/src/features/drawer/hooks/useScrollMemory.js new file mode 100644 index 000000000..d0892a576 --- /dev/null +++ b/src/features/drawer/hooks/useScrollMemory.js @@ -0,0 +1,90 @@ +// @ts-check +import * as React from 'react' + +/** @type {Map} */ +const gridStateMemory = new Map() + +/** @type {Map} */ +const scrollTopMemory = new Map() + +/** + * @param {string} key + * @returns {import('react-virtuoso').GridStateSnapshot | null} + */ +export function getDrawerGridState(key) { + return gridStateMemory.get(key) || null +} + +/** + * @param {string} key + * @param {import('react-virtuoso').GridStateSnapshot} state + */ +export function setDrawerGridState(key, state) { + gridStateMemory.set(key, state) +} + +/** + * @template {HTMLElement} T + * @param {string} key + * @param {boolean} [restore] + */ +export function useDrawerScrollMemory(key, restore = true) { + const nodeRef = React.useRef(/** @type {T | null} */ (null)) + const rafRef = React.useRef(0) + + const handleScroll = React.useCallback(() => { + if (nodeRef.current) { + scrollTopMemory.set(key, nodeRef.current.scrollTop) + } + }, [key]) + + const restoreScrollTop = React.useCallback( + /** @param {T | null} node */ + (node) => { + if (!node || !restore) return + if (rafRef.current) { + window.cancelAnimationFrame(rafRef.current) + } + rafRef.current = window.requestAnimationFrame(() => { + if (nodeRef.current === node) { + node.scrollTop = scrollTopMemory.get(key) || 0 + } + }) + }, + [key, restore], + ) + + const ref = React.useCallback( + /** @type {React.RefCallback} */ ( + (node) => { + if (nodeRef.current) { + nodeRef.current.removeEventListener('scroll', handleScroll) + } + nodeRef.current = node + if (node) { + node.addEventListener('scroll', handleScroll, { passive: true }) + restoreScrollTop(node) + } + } + ), + [handleScroll, restoreScrollTop], + ) + + React.useLayoutEffect(() => { + restoreScrollTop(nodeRef.current) + }, [restoreScrollTop]) + + React.useEffect( + () => () => { + if (rafRef.current) { + window.cancelAnimationFrame(rafRef.current) + } + if (nodeRef.current) { + nodeRef.current.removeEventListener('scroll', handleScroll) + } + }, + [handleScroll], + ) + + return { ref } +} diff --git a/src/features/drawer/index.jsx b/src/features/drawer/index.jsx index bc93323a3..b936f23d1 100644 --- a/src/features/drawer/index.jsx +++ b/src/features/drawer/index.jsx @@ -16,10 +16,8 @@ import { DividerWithMargin } from '@components/StyledDivider' import { DrawerActions } from './components/Actions' import { DrawerSectionMemo } from './components/Section' -const handleClose = () => useLayoutStore.setState({ drawer: false }) - const DrawerHeader = React.memo( - () => { + ({ onClose }) => { const title = useMemory((s) => s.config.general.title) return ( @@ -35,13 +33,13 @@ const DrawerHeader = React.memo( > {title} - + ) }, - () => true, + (prev, next) => prev.onClose === next.onClose, ) const listItemSx = /** @type {import('@mui/material').SxProps} */ ({ @@ -51,6 +49,21 @@ const listItemSx = /** @type {import('@mui/material').SxProps} */ ({ export function Drawer() { const drawer = useLayoutStore((s) => s.drawer) const { config, ui } = useMemory.getState() + const paperRef = React.useRef(/** @type {HTMLDivElement | null} */ (null)) + const scrollTopRef = React.useRef(0) + + const handleScroll = React.useCallback((e) => { + scrollTopRef.current = e.currentTarget.scrollTop + }, []) + + const handleClose = React.useCallback(() => { + scrollTopRef.current = paperRef.current?.scrollTop || 0 + useLayoutStore.setState({ drawer: false }) + }, []) + + const handleEnter = React.useCallback((node) => { + node.scrollTop = scrollTopRef.current + }, []) return ( - + {Object.entries(ui).map(([category, value]) => ( diff --git a/src/features/pokemon/PokemonPopup.jsx b/src/features/pokemon/PokemonPopup.jsx index 4fedc3f44..7a7848472 100644 --- a/src/features/pokemon/PokemonPopup.jsx +++ b/src/features/pokemon/PokemonPopup.jsx @@ -576,9 +576,8 @@ const Info = ({ pokemon, metaData, perms, timeOfDay, backgroundVisuals }) => { const darkMode = useStorage((s) => s.darkMode) const iconStyles = backgroundVisuals?.styles?.icon const hasBackground = Boolean(backgroundVisuals?.hasBackground) - const weatherIconTimeOfDay = hasBackground ? 'night' : timeOfDay - const weatherIconUrl = - Icons?.getWeather?.(weather, weatherIconTimeOfDay) || '' + const weatherIconUrl = Icons?.getWeather?.(weather, timeOfDay) || '' + const weatherIconColor = backgroundVisuals?.primaryColor || '#fff' return ( { maskPosition: 'center', WebkitMaskSize: 'contain', maskSize: 'contain', - backgroundColor: '#fff', + backgroundColor: weatherIconColor, } : { backgroundImage: `url(${weatherIconUrl})`, diff --git a/src/features/pokestop/PokestopPopup.jsx b/src/features/pokestop/PokestopPopup.jsx index 0f5e5129b..741990277 100644 --- a/src/features/pokestop/PokestopPopup.jsx +++ b/src/features/pokestop/PokestopPopup.jsx @@ -40,6 +40,13 @@ import { usePokemonBackgroundVisual, } from '@hooks/usePokemonBackgroundVisuals' import { resolveShowcaseEventIcon } from './resolveShowcaseEventIcon' +import { + INCIDENT_DISPLAY_TYPES, + getEventIncidentPriority, + getIncidentBlockReason, + getInvasionIncidentPriority, + isIncidentBlockedBy, +} from './incidentPriority' /** * @@ -48,6 +55,9 @@ import { resolveShowcaseEventIcon } from './resolveShowcaseEventIcon' * hasInvasion: boolean * hasQuest: boolean * hasEvent: boolean + * popupInvasions: import('@rm/types').Invasion[] + * popupEvents: import('@rm/types').Event[] + * incidentBlocker: { event: { display_type?: number | string | null }, priority: number } | null * }} props * @returns */ @@ -56,11 +66,15 @@ export function PokestopPopup({ hasInvasion, hasQuest, hasEvent, + popupInvasions, + popupEvents, + incidentBlocker, ...pokestop }) { const { t } = useTranslation() const Icons = useMemory((s) => s.Icons) - const { lure_expire_timestamp, lure_id, invasions, events } = pokestop + const { lure_expire_timestamp, lure_id } = pokestop + const incidentBlockReason = getIncidentBlockReason(incidentBlocker) useAnalytics( 'Popup', @@ -166,7 +180,7 @@ export function PokestopPopup({ )} {hasInvasion && ( <> - {invasions.map((invasion, index) => ( + {popupInvasions.map((invasion, index) => ( @@ -180,7 +194,14 @@ export function PokestopPopup({ invasion.grunt_type, invasion.confirmed, )} - disabled={pokestop.hasShowcase ? 'showcase_block' : ''} + disabled={ + isIncidentBlockedBy( + incidentBlocker, + getInvasionIncidentPriority(invasion), + ) + ? incidentBlockReason + : '' + } tt={ invasion.grunt_type === 44 && !invasion.confirmed ? [`grunt_a_${invasion.grunt_type}`, ' / ', 'decoy'] @@ -198,11 +219,12 @@ export function PokestopPopup({ {(hasQuest || hasLure || hasInvasion) && ( )} - {events.map(({ showcase_rankings, ...event }, index) => { + {popupEvents.map(({ showcase_rankings, ...event }, index) => { + const displayType = Number(event.display_type ?? 0) const { contest_entries = [], ...showcase } = showcase_rankings || { contest_entries: [] } const showcaseIcon = - event.display_type === 9 + displayType === INCIDENT_DISPLAY_TYPES.SHOWCASE ? resolveShowcaseEventIcon(event, Icons) : null return ( @@ -215,13 +237,16 @@ export function PokestopPopup({ ) : showcaseIcon ? ( showcaseIcon.url ) : ( - Icons.getEventStops(event.display_type) + Icons.getEventStops(displayType) ) } tt={t( - `display_type_${event.display_type}`, + `display_type_${displayType}`, t('unknown_event'), )} > diff --git a/src/features/pokestop/PokestopTile.jsx b/src/features/pokestop/PokestopTile.jsx index 8e64e49a7..39e4640b0 100644 --- a/src/features/pokestop/PokestopTile.jsx +++ b/src/features/pokestop/PokestopTile.jsx @@ -12,6 +12,10 @@ import { useManualPopupTracker } from '@hooks/useManualPopupTracker' import { TooltipWrapper } from '@components/ToolTipWrapper' import { PokestopPopup } from './PokestopPopup' +import { + INCIDENT_DISPLAY_TYPES, + getPokestopIncidentState, +} from './incidentPriority' import { usePokestopMarker } from './usePokestopMarker' /** @@ -22,6 +26,16 @@ import { usePokestopMarker } from './usePokestopMarker' const BasePokestopTile = (pokestop) => { const [stateChange, setStateChange] = React.useState(false) const [markerRef, setMarkerRef] = React.useState(null) + const ts = Date.now() / 1000 + const incidentState = getPokestopIncidentState({ + events: pokestop.events, + invasions: pokestop.invasions, + showcase_expiry: pokestop.showcase_expiry, + incident_blocker_display_type: pokestop.incident_blocker_display_type, + incident_blocker_expire_timestamp: + pokestop.incident_blocker_expire_timestamp, + ts, + }) const hasRoutes = useRouteStore( React.useCallback( (state) => @@ -37,111 +51,143 @@ const BasePokestopTile = (pokestop) => { const selectPoi = useRouteStore((s) => s.selectPoi) const [ - hasLure, - hasInvasion, - hasQuest, - hasEvent, - hasAllStops, - showTimer, + canShowLures, + canShowInvasions, + canShowQuests, + canShowEvents, + canShowPokestops, + hasTimerOverride, interactionRangeZoom, - hasShowcase, - ] = useMemory((s) => { - const newTs = Date.now() / 1000 - const { filters } = useStorage.getState() - const { - config, - timerList, - auth: { perms }, - } = s - return [ - pokestop.lure_expire_timestamp > newTs && perms.lures, - !!( - perms.invasions && - pokestop.invasions?.some( - (invasion) => - invasion.grunt_type && invasion.incident_expire_timestamp > newTs, - ) - ), - !!(perms.quests && pokestop.quests?.length), - !!( - perms.eventStops && - filters.pokestops.eventStops && - pokestop.events?.some((event) => event.event_expire_timestamp > newTs) - ), - (filters.pokestops.allPokestops || pokestop.ar_scan_eligible) && - perms.pokestops, - timerList.includes(pokestop.id), - config.general.interactionRangeZoom, - !!(perms.pokestops && pokestop.hasShowcase), - ] - }, basicEqualFn) + ] = useMemory( + (s) => [ + !!s.auth.perms.lures, + !!s.auth.perms.invasions, + !!s.auth.perms.quests, + !!s.auth.perms.eventStops, + !!s.auth.perms.pokestops, + s.timerList.includes(pokestop.id), + s.config.general.interactionRangeZoom, + ], + basicEqualFn, + ) const [ - invasionTimers, - lureTimers, - eventStopTimers, - lureRange, - showcaseRange, - interactionRange, + showEventStops, + showAllStops, + showInvasionTimers, + showLureTimers, + showEventStopTimers, + showLureRange, + showShowcaseRange, + showInteractionRange, customRange, + zoom, ] = useStorage((s) => { - const { userSettings, zoom } = s + const { userSettings } = s return [ - userSettings.pokestops.invasionTimers || showTimer, - userSettings.pokestops.lureTimers || showTimer, - userSettings.pokestops.eventStopTimers || showTimer, - !!userSettings.pokestops.lureRange && zoom >= interactionRangeZoom, - !!userSettings.pokestops.showcaseRange && - zoom >= interactionRangeZoom && - hasShowcase, - !!userSettings.pokestops.interactionRanges && - zoom >= interactionRangeZoom, - zoom >= interactionRangeZoom - ? +userSettings.pokestops.customRange || 0 - : 0, + !!s.filters.pokestops.eventStops, + !!s.filters.pokestops.allPokestops, + !!(userSettings.pokestops.invasionTimers || hasTimerOverride), + !!(userSettings.pokestops.lureTimers || hasTimerOverride), + !!(userSettings.pokestops.eventStopTimers || hasTimerOverride), + !!userSettings.pokestops.lureRange, + !!userSettings.pokestops.showcaseRange, + !!userSettings.pokestops.interactionRanges, + +userSettings.pokestops.customRange || 0, + s.zoom, ] }, basicEqualFn) - const timers = React.useMemo(() => { - const internalTimers = /** @type {number[]} */ ([]) - if (invasionTimers && hasInvasion) { - pokestop.invasions.forEach((invasion) => - internalTimers.push(invasion.incident_expire_timestamp), + const hasLure = pokestop.lure_expire_timestamp > ts && canShowLures + const hasQuest = !!(canShowQuests && pokestop.quests?.length) + const hasInvasion = !!( + canShowInvasions && incidentState.popupInvasions.length + ) + const hasEvent = !!( + canShowEvents && + showEventStops && + incidentState.popupEvents.length + ) + const visibleMarkerInvasions = canShowInvasions + ? incidentState.markerInvasions + : [] + const visibleMarkerEvents = + canShowEvents && showEventStops ? incidentState.markerEvents : [] + const hasVisibleInvasion = !!( + canShowInvasions && visibleMarkerInvasions.length + ) + const hasVisibleEvent = !!visibleMarkerEvents.length + const hasVisibleShowcase = visibleMarkerEvents.some( + (event) => + Number(event.display_type ?? 0) === INCIDENT_DISPLAY_TYPES.SHOWCASE, + ) + const hasAllStops = !!( + (showAllStops || pokestop.ar_scan_eligible) && + canShowPokestops + ) + const withinRangeZoom = zoom >= interactionRangeZoom + const lureRange = showLureRange && withinRangeZoom + const showcaseRange = + showShowcaseRange && withinRangeZoom && hasVisibleShowcase + const interactionRange = showInteractionRange && withinRangeZoom + const renderedCustomRange = withinRangeZoom ? customRange : 0 + + const [refreshTimers, tooltipTimers] = React.useMemo(() => { + const internalRefreshTimers = [...incidentState.expiryTimestamps] + const internalTooltipTimers = /** @type {number[]} */ ([]) + + if (showInvasionTimers && hasVisibleInvasion) { + visibleMarkerInvasions.forEach((invasion) => + internalTooltipTimers.push(invasion.incident_expire_timestamp), ) } - if (lureTimers && hasLure) { - internalTimers.push(pokestop.lure_expire_timestamp) + if (showLureTimers && hasLure) { + internalRefreshTimers.push(pokestop.lure_expire_timestamp) + internalTooltipTimers.push(pokestop.lure_expire_timestamp) } - if (eventStopTimers && hasEvent) { - pokestop.events.forEach((event) => { - internalTimers.push(event.event_expire_timestamp) + if (showEventStopTimers && hasVisibleEvent) { + visibleMarkerEvents.forEach((event) => { + internalTooltipTimers.push(event.event_expire_timestamp) }) } - return internalTimers + + return [internalRefreshTimers, internalTooltipTimers] }, [ - invasionTimers, - hasInvasion, - lureTimers, + incidentState.expiryTimestamps, + visibleMarkerEvents, + visibleMarkerInvasions, + showInvasionTimers, + hasVisibleInvasion, + showLureTimers, hasLure, - eventStopTimers, - hasEvent, + showEventStopTimers, + hasVisibleEvent, + pokestop.lure_expire_timestamp, ]) useForcePopup(pokestop.id, markerRef) - useMarkerTimer(timers.length ? Math.min(...timers) : null, markerRef, () => - setStateChange(!stateChange), + useMarkerTimer( + refreshTimers.length ? Math.min(...refreshTimers) : null, + markerRef, + () => setStateChange(!stateChange), ) const handlePopupOpen = useManualPopupTracker('pokestops', pokestop.id) const icon = usePokestopMarker({ hasQuest, hasLure, - hasInvasion, - hasEvent, + markerEvents: visibleMarkerEvents, + markerInvasions: visibleMarkerInvasions, + baseIncidentDisplay: + canShowEvents && showEventStops ? incidentState.baseDisplay : '', ...pokestop, }) - return hasQuest || hasLure || hasInvasion || hasEvent || hasAllStops ? ( + return hasQuest || + hasLure || + hasVisibleInvasion || + hasVisibleEvent || + hasAllStops ? ( { hasInvasion={hasInvasion} hasQuest={hasQuest} hasEvent={hasEvent} + popupInvasions={incidentState.popupInvasions} + popupEvents={incidentState.popupEvents} + incidentBlocker={incidentState.blocker} {...pokestop} /> - {Boolean(timers.length) && ( - + {Boolean(tooltipTimers.length) && ( + )} {interactionRange && ( { pathOptions={{ color: '#39a18f', weight: 1 }} /> )} - {!!customRange && ( + {!!renderedCustomRange && ( )} @@ -205,18 +254,31 @@ export const PokestopTile = React.memo( prev.id === next.id && prev.lure_expire_timestamp === next.lure_expire_timestamp && prev.updated === next.updated && - prev.hasShowcase === next.hasShowcase && + prev.showcase_expiry === next.showcase_expiry && + prev.incident_blocker_display_type === next.incident_blocker_display_type && + prev.incident_blocker_expire_timestamp === + next.incident_blocker_expire_timestamp && prev.quests?.length === next.quests?.length && (prev.quests && next.quests ? prev.quests.every((q, i) => q.with_ar === next.quests[i]?.with_ar) : true) && prev.invasions?.length === next.invasions?.length && (prev.invasions && next.invasions - ? prev.invasions?.every( + ? prev.invasions.every( (inv, i) => - inv.confirmed === next?.invasions?.[i]?.confirmed && - inv.grunt_type === next?.invasions?.[i]?.grunt_type, + inv.confirmed === next.invasions?.[i]?.confirmed && + inv.grunt_type === next.invasions?.[i]?.grunt_type && + inv.incident_expire_timestamp === + next.invasions?.[i]?.incident_expire_timestamp, ) : true) && - prev.events?.length === next.events?.length, + prev.events?.length === next.events?.length && + (prev.events && next.events + ? prev.events.every( + (event, i) => + event.display_type === next.events?.[i]?.display_type && + event.event_expire_timestamp === + next.events?.[i]?.event_expire_timestamp, + ) + : true), ) diff --git a/src/features/pokestop/incidentPriority.js b/src/features/pokestop/incidentPriority.js new file mode 100644 index 000000000..137bc3117 --- /dev/null +++ b/src/features/pokestop/incidentPriority.js @@ -0,0 +1,311 @@ +// @ts-check + +// Larger values are stronger display precedence, matching scanner incident priorities. +export const INCIDENT_PRIORITY_SETTINGS = Object.freeze({ + INCIDENT_CONTEST: 7, + INVASION_GENERIC: 6, + INVASION_GIOVANNI: 5, + INVASION_LEADER: 4, + INVASION_GRUNT: 3, + INVASION_EVENT_NPC: 2, + INCIDENT_POKESTOP_ENCOUNTER: 1, +}) + +export const INCIDENT_DISPLAY_TYPES = Object.freeze({ + GOLD_STOP: 7, + KECLEON: 8, + SHOWCASE: 9, +}) + +/** + * @param {{ display_type?: number | string | null }} event + * @returns {number} + */ +export function getEventIncidentPriority(event) { + switch (Number(event?.display_type ?? 0)) { + case INCIDENT_DISPLAY_TYPES.SHOWCASE: + return INCIDENT_PRIORITY_SETTINGS.INCIDENT_CONTEST + case INCIDENT_DISPLAY_TYPES.GOLD_STOP: + return INCIDENT_PRIORITY_SETTINGS.INVASION_GENERIC + case INCIDENT_DISPLAY_TYPES.KECLEON: + return INCIDENT_PRIORITY_SETTINGS.INCIDENT_POKESTOP_ENCOUNTER + default: + return event?.display_type + ? INCIDENT_PRIORITY_SETTINGS.INVASION_EVENT_NPC + : 0 + } +} + +/** + * @param {{ grunt_type?: number | string | null }} invasion + * @returns {number} + */ +export function getInvasionIncidentPriority(invasion) { + const gruntType = Number(invasion?.grunt_type ?? 0) + if (!gruntType) return 0 + if (gruntType === 44) { + return INCIDENT_PRIORITY_SETTINGS.INVASION_GIOVANNI + } + if (gruntType >= 41 && gruntType <= 43) { + return INCIDENT_PRIORITY_SETTINGS.INVASION_LEADER + } + return INCIDENT_PRIORITY_SETTINGS.INVASION_GRUNT +} + +/** + * @param {{ display_type?: number | string | null, event_expire_timestamp?: number | string | null }} event + * @returns {boolean} + */ +export function isActiveEvent(event, ts) { + return Number(event?.event_expire_timestamp ?? 0) > ts +} + +/** + * @param {{ grunt_type?: number | string | null, incident_expire_timestamp?: number | string | null }} invasion + * @returns {boolean} + */ +export function isActiveInvasion(invasion, ts) { + return ( + Number(invasion?.grunt_type ?? 0) > 0 && + Number(invasion?.incident_expire_timestamp ?? 0) > ts + ) +} + +/** + * @param {{ event: { display_type?: number | string | null }, priority: number, expireTimestamp?: number | null } | null} current + * @param {{ event: { display_type?: number | string | null }, priority: number, expireTimestamp?: number | null } | null} next + * @returns {{ event: { display_type?: number | string | null }, priority: number, expireTimestamp?: number | null } | null} + */ +export function getStrongerIncidentBlocker(current, next) { + if (!current) return next + if (!next) return current + if (next.priority !== current.priority) { + return next.priority > current.priority ? next : current + } + if ( + Number(next.event.display_type ?? 0) !== + Number(current.event.display_type ?? 0) + ) { + return Number(next.event.display_type ?? 0) > + Number(current.event.display_type ?? 0) + ? next + : current + } + return Number(next.expireTimestamp ?? 0) > + Number(current.expireTimestamp ?? 0) + ? next + : current +} + +/** + * @param {{ + * events?: Array<{ display_type?: number | string | null, event_expire_timestamp?: number | string | null }> + * }} param0 + * @returns {{ event: { display_type?: number | string | null }, priority: number, expireTimestamp?: number | null } | null} + */ +export function getVisibleIncidentBlocker({ events = [] } = {}) { + return events.reduce((strongestEvent, event) => { + const priority = getEventIncidentPriority(event) + if (priority < INCIDENT_PRIORITY_SETTINGS.INVASION_GENERIC) { + return strongestEvent + } + return getStrongerIncidentBlocker(strongestEvent, { + event, + priority, + expireTimestamp: Number(event.event_expire_timestamp ?? 0) || null, + }) + }, null) +} + +/** + * @param {{ + * showcase_expiry?: number | string | null + * ts: number + * }} param0 + * @returns {{ event: { display_type?: number | string | null }, priority: number, expireTimestamp?: number | null } | null} + */ +export function getShowcaseIncidentBlocker({ showcase_expiry, ts } = {}) { + const showcaseExpiry = Number(showcase_expiry ?? 0) || null + if (!showcaseExpiry || showcaseExpiry <= ts) { + return null + } + + const showcaseEvent = { display_type: INCIDENT_DISPLAY_TYPES.SHOWCASE } + return { + event: showcaseEvent, + priority: getEventIncidentPriority(showcaseEvent), + expireTimestamp: showcaseExpiry, + } +} + +/** + * @param {{ + * incident_blocker_display_type?: number | string | null + * incident_blocker_expire_timestamp?: number | string | null + * ts: number + * }} param0 + * @returns {{ event: { display_type?: number | string | null }, priority: number, expireTimestamp?: number | null } | null} + */ +export function getFallbackIncidentBlocker({ + incident_blocker_display_type, + incident_blocker_expire_timestamp, + ts, +} = {}) { + const fallbackDisplayType = Number(incident_blocker_display_type ?? 0) || 0 + const fallbackExpireTimestamp = + Number(incident_blocker_expire_timestamp ?? 0) || null + + if ( + (fallbackDisplayType !== INCIDENT_DISPLAY_TYPES.GOLD_STOP && + fallbackDisplayType !== INCIDENT_DISPLAY_TYPES.SHOWCASE) || + !fallbackExpireTimestamp || + fallbackExpireTimestamp <= ts + ) { + return null + } + + const fallbackEvent = { display_type: fallbackDisplayType } + return { + event: fallbackEvent, + priority: getEventIncidentPriority(fallbackEvent), + expireTimestamp: fallbackExpireTimestamp, + } +} + +/** + * @param {{ + * events?: Array<{ display_type?: number | string | null, event_expire_timestamp?: number | string | null }> + * invasions?: Array<{ grunt_type?: number | string | null, incident_expire_timestamp?: number | string | null }> + * showcase_expiry?: number | string | null + * incident_blocker_display_type?: number | string | null + * incident_blocker_expire_timestamp?: number | string | null + * ts: number + * }} param0 + */ +export function getPokestopIncidentState({ + events, + invasions, + showcase_expiry, + incident_blocker_display_type, + incident_blocker_expire_timestamp, + ts, +} = {}) { + const normalizedEvents = Array.isArray(events) ? events : [] + const normalizedInvasions = Array.isArray(invasions) ? invasions : [] + const popupEvents = normalizedEvents.filter((event) => + isActiveEvent(event, ts), + ) + const popupInvasions = normalizedInvasions.filter((invasion) => + isActiveInvasion(invasion, ts), + ) + const visibleBlocker = getVisibleIncidentBlocker({ + events: popupEvents, + }) + const showcaseBlocker = getShowcaseIncidentBlocker({ + showcase_expiry, + ts, + }) + const fallbackBlocker = getFallbackIncidentBlocker({ + incident_blocker_display_type, + incident_blocker_expire_timestamp, + ts, + }) + const blocker = getStrongerIncidentBlocker( + getStrongerIncidentBlocker(visibleBlocker, showcaseBlocker), + fallbackBlocker, + ) + const markerEvents = blocker + ? popupEvents.filter( + (event) => getEventIncidentPriority(event) >= blocker.priority, + ) + : popupEvents + const markerInvasions = blocker ? [] : popupInvasions + const baseDisplay = getBasePokestopIncidentDisplay({ + events: markerEvents, + invasions: markerInvasions, + }) + const expiryTimestamps = [ + ...new Set( + [ + ...popupInvasions.map((invasion) => + Number(invasion.incident_expire_timestamp ?? 0), + ), + ...popupEvents.map((event) => + Number(event.event_expire_timestamp ?? 0), + ), + Number(blocker?.expireTimestamp ?? 0), + ].filter(Boolean), + ), + ] + + return { + blocker, + popupEvents, + popupInvasions, + markerEvents, + markerInvasions, + baseDisplay, + expiryTimestamps, + } +} + +/** + * @param {{ + * events?: Array<{ display_type?: number | string | null }> + * invasions?: Array<{ grunt_type?: number | string | null }> + * }} param0 + * @returns {number | string} + */ +export function getBasePokestopIncidentDisplay({ + events = [], + invasions = [], +} = {}) { + const strongestVisibleEvent = events.reduce((strongest, event) => { + if (!strongest) return event + const strongestPriority = getEventIncidentPriority(strongest) + const priority = getEventIncidentPriority(event) + if (priority !== strongestPriority) { + return priority > strongestPriority ? event : strongest + } + return Number(event.display_type ?? 0) > Number(strongest.display_type ?? 0) + ? event + : strongest + }, null) + + const strongestEventPriority = strongestVisibleEvent + ? getEventIncidentPriority(strongestVisibleEvent) + : 0 + const strongestInvasionPriority = invasions.reduce( + (maxPriority, invasion) => + Math.max(maxPriority, getInvasionIncidentPriority(invasion)), + 0, + ) + + return strongestEventPriority > strongestInvasionPriority + ? Number(strongestVisibleEvent?.display_type ?? 0) || '' + : '' +} + +/** + * @param {{ priority: number } | null} blocker + * @returns {string} + */ +export function getIncidentBlockReason(blocker) { + switch (blocker?.priority) { + case INCIDENT_PRIORITY_SETTINGS.INCIDENT_CONTEST: + return 'showcase_block' + case INCIDENT_PRIORITY_SETTINGS.INVASION_GENERIC: + return 'gold_stop_block' + default: + return '' + } +} + +/** + * @param {{ priority: number } | null} blocker + * @param {number} priority + * @returns {boolean} + */ +export function isIncidentBlockedBy(blocker, priority) { + return !!blocker && blocker.priority > priority +} diff --git a/src/features/pokestop/usePokestopMarker.js b/src/features/pokestop/usePokestopMarker.js index 5ddcebd88..4517d6db5 100644 --- a/src/features/pokestop/usePokestopMarker.js +++ b/src/features/pokestop/usePokestopMarker.js @@ -4,6 +4,7 @@ import { divIcon } from 'leaflet' import { basicEqualFn, useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { useOpacity } from '@hooks/useOpacity' +import { INCIDENT_DISPLAY_TYPES } from './incidentPriority' import { resolveShowcaseEventIcon } from './resolveShowcaseEventIcon' /** @@ -11,23 +12,22 @@ import { resolveShowcaseEventIcon } from './resolveShowcaseEventIcon' * @param {{ * hasQuest: boolean, * hasLure: boolean, - * hasInvasion: boolean, - * hasEvent: boolean, + * markerEvents: Array<{ display_type?: number | string | null }>, + * markerInvasions: Array, + * baseIncidentDisplay: number | string, * } & import('@rm/types').Pokestop} param0 * @returns */ export function usePokestopMarker({ hasQuest, hasLure, - hasInvasion, - hasEvent, lure_id, ar_scan_eligible, power_up_level, - events, - invasions, quests, - hasShowcase, + markerEvents, + markerInvasions, + baseIncidentDisplay, }) { const [, Icons, masterfile] = useStorage( (s) => [ @@ -38,6 +38,18 @@ export function usePokestopMarker({ (a, b) => Object.entries(a[0]).every(([k, v]) => b[0][k] === v), ) + const hasVisibleInvasion = markerInvasions.some( + (invasion) => !!invasion.grunt_type, + ) + const shouldShowStandaloneKecleonBadge = + !hasQuest && + !hasVisibleInvasion && + markerEvents.length > 0 && + markerEvents.every( + (event) => + Number(event.display_type ?? 0) === INCIDENT_DISPLAY_TYPES.KECLEON, + ) + const getOpacity = useOpacity('pokestops', 'invasion') const [ showArBadge, @@ -54,11 +66,11 @@ export function usePokestopMarker({ pokestops.showNoArQuestDotBadge ?? true, Icons.getPokestops( hasLure ? lure_id : 0, - hasInvasion, + hasVisibleInvasion, hasQuest && pokestops.hasQuestIndicator, ar_scan_eligible && (pokestops.showArBadge || !!power_up_level), power_up_level, - hasEvent ? Math.max(...events.map((event) => event.display_type)) : 0, + baseIncidentDisplay, ), hasLure ? Icons.getSize( @@ -85,12 +97,11 @@ export function usePokestopMarker({ const invasionSizes = [] const questIcons = [] const questSizes = [] - const showcaseIcons = [] - const showcaseSizes = [] - const canShowInvasionIcons = hasInvasion && !hasShowcase + const eventIcons = [] + const eventSizes = [] - if (canShowInvasionIcons) { - invasions.forEach((invasion) => { + if (hasVisibleInvasion) { + markerInvasions.forEach((invasion) => { if (invasion.grunt_type) { invasionIcons.unshift({ icon: Icons.getInvasions(invasion.grunt_type, invasion.confirmed), @@ -254,7 +265,7 @@ export function usePokestopMarker({ }) } - if (hasQuest && !(hasInvasion && invasionMod?.removeQuest)) { + if (hasQuest && !(hasVisibleInvasion && invasionMod?.removeQuest)) { quests.forEach((quest) => { const { quest_item_id, @@ -365,25 +376,38 @@ export function usePokestopMarker({ popupY += rewardMod.popupY }) } - if (hasEvent && !canShowInvasionIcons && !hasQuest) { - events.forEach((event) => { - if (event.display_type === 8) { - // Only show Kecleon if there's no active showcase blocking it - if (!hasShowcase) { - showcaseIcons.unshift({ - url: Icons.getPokemon(352), - }) - showcaseSizes.unshift(Icons.getSize('event', filters.b7?.size)) + if (markerEvents.length && !hasQuest) { + markerEvents.forEach((event) => { + const displayType = Number(event.display_type ?? 0) + if (displayType === INCIDENT_DISPLAY_TYPES.KECLEON) { + if (!shouldShowStandaloneKecleonBadge || eventIcons.length) { + return } - } else if (event.display_type === 9) { + eventIcons.unshift({ + url: Icons.getPokemon(352), + }) + eventSizes.unshift( + Icons.getSize( + 'event', + filters[`b${INCIDENT_DISPLAY_TYPES.KECLEON}`]?.size, + ), + ) + } else if (displayType === INCIDENT_DISPLAY_TYPES.SHOWCASE) { const showcaseIcon = resolveShowcaseEventIcon(event, Icons) - showcaseIcons.unshift({ + eventIcons.unshift({ url: showcaseIcon.url, decoration: showcaseIcon.decoration, }) - showcaseSizes.unshift( + eventSizes.unshift( Icons.getSize('event', filters[showcaseIcon.sizeFilterKey]?.size), ) + } else { + eventIcons.unshift({ + url: Icons.getEventStops(displayType), + }) + eventSizes.unshift( + Icons.getSize('event', filters[`b${displayType}`]?.size), + ) } popupYOffset += eventMod.offsetY - 1 popupX += eventMod.popupX @@ -392,7 +416,9 @@ export function usePokestopMarker({ } const totalQuestSize = questSizes.reduce((a, b) => a + b, 0) const totalInvasionSize = invasionSizes.reduce((a, b) => a + b, 0) - const totalShowcaseSize = showcaseSizes.reduce((a, b) => a + b, -3) + const totalEventSize = eventSizes.length + ? eventSizes.reduce((a, b) => a + b, -3) + : 0 const showAr = showArBadge && ar_scan_eligible && !baseIcon.includes('_ar') @@ -421,11 +447,11 @@ export function usePokestopMarker({ }) }) - showcaseIcons.forEach((icon, index) => { + eventIcons.forEach((icon, index) => { stackItems.push({ type: 'event', url: icon.url, - size: showcaseSizes[index], + size: eventSizes[index], modifier: eventMod, decoration: icon.decoration, }) @@ -539,7 +565,7 @@ export function usePokestopMarker({ ? pokestopMod.manualPopup - totalInvasionSize * 0.25 - totalQuestSize * 0.1 - : -(baseSize + totalInvasionSize + totalQuestSize + totalShowcaseSize) / + : -(baseSize + totalInvasionSize + totalQuestSize + totalEventSize) / popupYOffset) + popupY, ], className: 'pokestop-marker', diff --git a/src/features/scanner/ScanDialog.jsx b/src/features/scanner/ScanDialog.jsx index 79dc52836..53f0c5479 100644 --- a/src/features/scanner/ScanDialog.jsx +++ b/src/features/scanner/ScanDialog.jsx @@ -1,13 +1,8 @@ // @ts-check import * as React from 'react' -import Dialog from '@mui/material/Dialog' -import DialogContent from '@mui/material/DialogContent' -import Typography from '@mui/material/Typography' import { useTranslation } from 'react-i18next' -import { Header } from '@components/dialogs/Header' -import { Footer } from '@components/dialogs/Footer' -import { SCAN_MODES } from '@assets/constants' +import { Notification } from '@components/Notification' import { useScanStore } from './hooks/store' @@ -28,33 +23,30 @@ export function ScanDialog() { if (scanZone) return setScanMode('scanZoneMode') }, [scanNext, scanZone]) - const footerOptions = React.useMemo( - () => - /** @type {import('@components/dialogs/Footer').FooterButton[]} */ ([ - { - name: 'close', - icon: 'Clear', - color: 'primary', - align: 'right', - action: handleClose, - }, - ]), - [handleClose], - ) + const resultOpen = scanMode === 'confirmed' || scanMode === 'error' + const resultSeverity = scanMode === 'error' ? 'error' : 'success' return ( - -
- - - {scanMode && t(`scan_${scanMode}`)} - - -
-
+ <> + + {t('scan_loading')} + + + {resultOpen ? t(`scan_${scanMode}`) : null} + + ) } diff --git a/src/features/weather/ActiveWeather.jsx b/src/features/weather/ActiveWeather.jsx index d7ffcf2d3..e43c45f25 100644 --- a/src/features/weather/ActiveWeather.jsx +++ b/src/features/weather/ActiveWeather.jsx @@ -10,7 +10,6 @@ import { useTranslation } from 'react-i18next' import { useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' -import { apolloClient } from '@services/apollo' import { Header } from '@components/dialogs/Header' import { Footer } from '@components/dialogs/Footer' import { Img } from '@components/Img' @@ -89,42 +88,27 @@ function Weather({ gameplay_condition, ...props }) { ) } -const WeatherMemo = React.memo( - Weather, - (prev, next) => prev.gameplay_condition === next.gameplay_condition, -) - -export function ActiveWeather() { +/** + * @param {{ weatherData: import('@rm/types').Weather[] }} props + */ +export function ActiveWeather({ weatherData }) { const weatherEnabled = useStorage((s) => s.filters?.weather?.enabled ?? false) const location = useStorage((s) => s.location) const zoom = useStorage((s) => s.zoom) const allowedZoom = useMemory((s) => s.config.general.activeWeatherZoom) - const [active, setActive] = React.useState( - /** @type {import('@rm/types').Weather | null} */ (null), + const active = React.useMemo( + () => + zoom > allowedZoom + ? weatherData.find( + (cell) => + Array.isArray(cell?.polygon) && + booleanPointInPolygon(point(location), polygon([cell.polygon])), + ) || null + : null, + [allowedZoom, location, weatherData, zoom], ) - React.useEffect(() => { - if (zoom > allowedZoom) { - const weatherCache = Object.values(apolloClient.cache.extract()).find( - (x) => - x.__typename === 'Weather' && - // @ts-ignore - booleanPointInPolygon(point(location), polygon([x.polygon])), - ) - if ( - weatherCache && - 'gameplay_condition' in weatherCache && - weatherCache?.gameplay_condition !== active?.gameplay_condition - ) { - // @ts-ignore - setActive(weatherCache) - } - } else { - setActive(null) - } - }, [location, zoom, allowedZoom]) - if (!weatherEnabled || !active) return null - return + return } diff --git a/src/features/weather/WeatherPopup.jsx b/src/features/weather/WeatherPopup.jsx index 541aff818..1fad5dff6 100644 --- a/src/features/weather/WeatherPopup.jsx +++ b/src/features/weather/WeatherPopup.jsx @@ -89,11 +89,12 @@ const Timer = ({ updated, ts = Date.now() / 1000 }) => { : 'error.main' React.useEffect(() => { - const timer2 = setTimeout(() => { + setTimer(getTimeUntil(updated * 1000)) + const timerId = setInterval(() => { setTimer(getTimeUntil(updated * 1000)) }, 1000) - return () => clearTimeout(timer2) - }) + return () => clearInterval(timerId) + }, [updated]) return ( <> diff --git a/src/pages/map/components/Container.jsx b/src/pages/map/components/Container.jsx index ffacef9a8..15db7a1a9 100644 --- a/src/pages/map/components/Container.jsx +++ b/src/pages/map/components/Container.jsx @@ -7,7 +7,6 @@ import { useStorage } from '@store/useStorage' import { useMapStore } from '@store/useMapStore' import { ScanOnDemand } from '@features/scanner' import { WebhookMarker, WebhookAreaSelection } from '@features/webhooks' -import { ActiveWeather } from '@features/weather' import { timeCheck } from '@utils/timeCheck' import { Effects } from './Effects' @@ -66,7 +65,6 @@ export function Container() {