diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index ffb49a834..e26fafd75 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -9,6 +9,8 @@ const config = require('@rm/config') const { getAreaSql } = require('../utils/getAreaSql') const { state } = require('../services/state') +const { applyManualIdFilter } = require('../utils/manualFilter') + const coreFields = [ 'id', 'name', @@ -83,6 +85,7 @@ class Gym extends Model { onlyGymBadges, onlyBadge, onlyAreas = [], + onlyManualId, } = args.filters const ts = Math.floor(Date.now() / 1000) const query = this.query() @@ -130,9 +133,22 @@ class Gym extends Model { } else if (hideOldGyms) { query.where('updated', '>', ts - gymValidDataLimit * 86400) } - query - .whereBetween(isMad ? 'latitude' : 'lat', [args.minLat, args.maxLat]) - .andWhereBetween(isMad ? 'longitude' : 'lon', [args.minLon, args.maxLon]) + const latCol = isMad ? 'latitude' : 'lat' + const lonCol = isMad ? 'longitude' : 'lon' + const idCol = isMad ? 'gym.gym_id' : 'id' + + applyManualIdFilter(query, { + manualId: onlyManualId, + latColumn: latCol, + lonColumn: lonCol, + idColumn: idCol, + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) Gym.onlyValid(query, isMad) const raidBosses = new Set() diff --git a/server/src/models/Nest.js b/server/src/models/Nest.js index 4a7020fe4..7bb9af8f2 100644 --- a/server/src/models/Nest.js +++ b/server/src/models/Nest.js @@ -5,6 +5,7 @@ const config = require('@rm/config') const { state } = require('../services/state') const { getAreaSql } = require('../utils/getAreaSql') +const { applyManualIdFilter } = require('../utils/manualFilter') /** @typedef {Nest & Partial} FullNest */ @@ -27,11 +28,14 @@ class Nest extends Model { static async getAll(perms, args, { polygon }) { const { areaRestrictions } = perms const { minLat, minLon, maxLat, maxLon, filters } = args - const query = this.query() - .select(['*', 'nest_id AS id']) - // .whereNotNull('pokemon_id') - .whereBetween('lat', [minLat, maxLat]) - .andWhereBetween('lon', [minLon, maxLon]) + const query = this.query().select(['*', 'nest_id AS id']) + applyManualIdFilter(query, { + manualId: filters.onlyManualId, + latColumn: 'lat', + lonColumn: 'lon', + idColumn: 'nest_id', + bounds: { minLat, maxLat, minLon, maxLon }, + }) const pokemon = [] if (filters.onlyPokemon) { diff --git a/server/src/models/Pokemon.js b/server/src/models/Pokemon.js index 692b1bb05..62a296a17 100644 --- a/server/src/models/Pokemon.js +++ b/server/src/models/Pokemon.js @@ -14,6 +14,10 @@ const config = require('@rm/config') const { getAreaSql } = require('../utils/getAreaSql') const { filterRTree } = require('../utils/filterRTree') const { fetchJson } = require('../utils/fetchJson') +const { + applyManualIdFilter, + normalizeManualId, +} = require('../utils/manualFilter') const { IV_CALC, LEVEL_CALC, @@ -148,6 +152,10 @@ class Pokemon extends Model { const query = this.query() + const manualIdFilter = normalizeManualId(args.filters.onlyManualId) + + let manualId = manualIdFilter + const pokemonIds = [] const pokemonForms = [] Object.values(filterMap).forEach((filter) => { @@ -169,75 +177,81 @@ class Pokemon extends Model { } else { query.select(['*', hasSize && !hasHeight ? 'size AS height' : 'size']) } - query - .where( - isMad ? 'disappear_time' : 'expire_timestamp', - '>=', - isMad ? this.knex().fn.now() : ts, - ) - .andWhereBetween(isMad ? 'pokemon.latitude' : 'lat', [ - args.minLat, - args.maxLat, - ]) - .andWhereBetween(isMad ? 'pokemon.longitude' : 'lon', [ - args.minLon, - args.maxLon, - ]) - .andWhere((ivOr) => { - if (ivs || pvp) { - if (globalFilter.filterKeys.size) { - ivOr.andWhere((pkmn) => { - const keys = globalFilter.keyArray - for (let i = 0; i < keys.length; i += 1) { - const key = keys[i] - switch (key) { - case 'xxs': - case 'xxl': - if (hasSize) { - pkmn.orWhere('pokemon.size', key === 'xxl' ? 5 : 1) - } - break - case 'gender': - pkmn.andWhere('pokemon.gender', onlyIvOr[key]) - break - case 'cp': - case 'level': - case 'atk_iv': - case 'def_iv': - case 'sta_iv': - case 'iv': - if (perms.iv) { - pkmn.andWhereBetween( - isMad ? MAD_KEY_MAP[key] : key, - onlyIvOr[key], - ) - } - break - default: - if ( - perms.pvp && - BASE_KEYS.every((x) => !globalFilter.filterKeys.has(x)) - ) { - // doesn't return everything if only pvp stats for individual pokemon - pkmn.whereNull('pokemon_id') - } - break - } + query.where( + isMad ? 'disappear_time' : 'expire_timestamp', + '>=', + isMad ? this.knex().fn.now() : ts, + ) + manualId = applyManualIdFilter(query, { + manualId: manualIdFilter, + latColumn: isMad ? 'pokemon.latitude' : 'lat', + lonColumn: isMad ? 'pokemon.longitude' : 'lon', + idColumn: isMad ? 'pokemon.encounter_id' : 'id', + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) + query.andWhere((ivOr) => { + if (ivs || pvp) { + if (globalFilter.filterKeys.size) { + ivOr.andWhere((pkmn) => { + const keys = globalFilter.keyArray + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i] + switch (key) { + case 'xxs': + case 'xxl': + if (hasSize) { + pkmn.orWhere('pokemon.size', key === 'xxl' ? 5 : 1) + } + break + case 'gender': + pkmn.andWhere('pokemon.gender', onlyIvOr[key]) + break + case 'cp': + case 'level': + case 'atk_iv': + case 'def_iv': + case 'sta_iv': + case 'iv': + if (perms.iv) { + pkmn.andWhereBetween( + isMad ? MAD_KEY_MAP[key] : key, + onlyIvOr[key], + ) + } + break + default: + if ( + perms.pvp && + BASE_KEYS.every((x) => !globalFilter.filterKeys.has(x)) + ) { + // doesn't return everything if only pvp stats for individual pokemon + pkmn.whereNull('pokemon_id') + } + break } - }) - } else { - ivOr.whereNull('pokemon_id') - } - ivOr.orWhereIn('pokemon_id', pokemonIds) - ivOr.orWhereIn('pokemon.form', pokemonForms) - } - if (onlyZeroIv && ivs) { - ivOr.orWhere(isMad ? raw(IV_CALC) : 'iv', 0) - } - if (onlyHundoIv && ivs) { - ivOr.orWhere(isMad ? raw(IV_CALC) : 'iv', 100) + } + }) + } else { + ivOr.whereNull('pokemon_id') } - }) + ivOr.orWhereIn('pokemon_id', pokemonIds) + ivOr.orWhereIn('pokemon.form', pokemonForms) + } + if (onlyZeroIv && ivs) { + ivOr.orWhere(isMad ? raw(IV_CALC) : 'iv', 0) + } + if (onlyHundoIv && ivs) { + ivOr.orWhere(isMad ? raw(IV_CALC) : 'iv', 100) + } + if (manualId !== null) { + ivOr.orWhereIn(isMad ? 'pokemon.encounter_id' : 'id', [manualId]) + } + }) if (!getAreaSql(query, areaRestrictions, onlyAreas, isMad, 'pokemon')) { return [] } @@ -272,7 +286,7 @@ class Pokemon extends Model { filters.push({ iv: { min: 100, max: 100 }, pokemon: globalPokes }) } /** @type {import("@rm/types").Pokemon[]} */ - const results = await this.evalQuery( + let results = await this.evalQuery( mem ? `${mem}/api/pokemon/v2/scan` : null, mem ? JSON.stringify({ @@ -293,6 +307,30 @@ class Pokemon extends Model { httpAuth, ) + if (mem && manualId !== null) { + const loadedIds = Array.isArray(results) + ? new Set(results.map((pkmn) => `${pkmn.id}`)) + : new Set() + if (!loadedIds.has(`${manualId}`)) { + const manualResult = await this.evalQuery( + `${mem}/api/pokemon/id/${manualId}`, + null, + 'GET', + secret, + httpAuth, + ).catch(() => null) + if (manualResult) { + results = Array.isArray(results) + ? [...results, manualResult] + : [manualResult] + } + } + } + + if (!Array.isArray(results)) { + results = [] + } + const finalResults = [] const pvpResults = [] const listOfIds = [] @@ -326,20 +364,23 @@ class Pokemon extends Model { if (isMad) { Pokemon.getMadSql(pvpQuery) } - pvpQuery - .where( - isMad ? 'disappear_time' : 'expire_timestamp', - '>=', - isMad ? this.knex().fn.now() : ts, - ) - .andWhereBetween(isMad ? 'pokemon.latitude' : 'lat', [ - args.minLat, - args.maxLat, - ]) - .andWhereBetween(isMad ? 'pokemon.longitude' : 'lon', [ - args.minLon, - args.maxLon, - ]) + pvpQuery.where( + isMad ? 'disappear_time' : 'expire_timestamp', + '>=', + isMad ? this.knex().fn.now() : ts, + ) + applyManualIdFilter(pvpQuery, { + manualId, + latColumn: isMad ? 'pokemon.latitude' : 'lat', + lonColumn: isMad ? 'pokemon.longitude' : 'lon', + idColumn: isMad ? 'pokemon.encounter_id' : 'id', + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) if (isMad && listOfIds.length) { pvpQuery.whereRaw( `pokemon.encounter_id NOT IN ( ${listOfIds.join(',')} )`, @@ -643,6 +684,7 @@ class Pokemon extends Model { const { isMad, hasSize, hasHeight, mem, secret, httpAuth } = ctx const ts = Math.floor(Date.now() / 1000) const { filterMap, globalFilter } = this.getFilters(perms, args, ctx) + const manualIdFilter = normalizeManualId(args.filters.onlyManualId) const queryLimits = config.getSafe('api.queryLimits') if (!perms.iv && !perms.pvp) { @@ -682,14 +724,25 @@ class Pokemon extends Model { ) { return [] } - + const manualId = applyManualIdFilter(query, { + manualId: manualIdFilter, + latColumn: isMad ? 'pokemon.latitude' : 'lat', + lonColumn: isMad ? 'pokemon.longitude' : 'lon', + idColumn: isMad ? 'pokemon.encounter_id' : 'id', + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) const filters = mem ? Object.values(filterMap).flatMap((filter) => filter.buildApiFilter()) : [] if ((perms.iv || perms.pvp) && mem) filters.push(...globalFilter.buildApiFilter()) - const results = await this.evalQuery( + let results = await this.evalQuery( mem ? `${mem}/api/pokemon/v2/scan` : null, mem ? JSON.stringify({ @@ -709,6 +762,22 @@ class Pokemon extends Model { secret, httpAuth, ) + + if (mem && manualId !== null) { + const loaded = new Set(results.map((pkmn) => `${pkmn.id}`)) + if (!loaded.has(`${manualId}`)) { + const manualResult = await this.evalQuery( + `${mem}/api/pokemon/id/${manualId}`, + null, + 'GET', + secret, + httpAuth, + ).catch(() => null) + if (manualResult) { + results = [...results, manualResult] + } + } + } const filtered = results.filter( (item) => !mem || diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 05a06a76d..0ffff1032 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -6,6 +6,7 @@ const i18next = require('i18next') const config = require('@rm/config') const { getAreaSql } = require('../utils/getAreaSql') +const { applyManualIdFilter } = require('../utils/manualFilter') const { getUserMidnight } = require('../utils/getClientTime') const { state } = require('../services/state') @@ -138,7 +139,6 @@ class Pokestop extends Model { const ts = Math.floor(Date.now() / 1000) const { queryLimits, stopValidDataLimit, hideOldPokestops } = config.getSafe('api') - const { lures: lurePerms, quests: questPerms, @@ -184,9 +184,18 @@ class Pokestop extends Model { query.where('pokestop.updated', '>', ts - stopValidDataLimit * 86400) } Pokestop.joinIncident(query, hasMultiInvasions, isMad, multiInvasionMs) - query - .whereBetween(isMad ? 'latitude' : 'lat', [args.minLat, args.maxLat]) - .andWhereBetween(isMad ? 'longitude' : 'lon', [args.minLon, args.maxLon]) + applyManualIdFilter(query, { + manualId: args.filters.onlyManualId, + latColumn: isMad ? 'latitude' : 'lat', + lonColumn: isMad ? 'longitude' : 'lon', + idColumn: isMad ? 'pokestop.pokestop_id' : 'id', + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) if (!getAreaSql(query, areaRestrictions, onlyAreas, isMad)) { return [] diff --git a/server/src/models/Portal.js b/server/src/models/Portal.js index 79a9d3aa1..75beef5df 100644 --- a/server/src/models/Portal.js +++ b/server/src/models/Portal.js @@ -3,6 +3,7 @@ const { Model } = require('objection') const config = require('@rm/config') const { getAreaSql } = require('../utils/getAreaSql') +const { applyManualIdFilter } = require('../utils/manualFilter') class Portal extends Model { static get tableName() { @@ -29,13 +30,18 @@ class Portal extends Model { maxLon, } = args const query = this.query() - .whereBetween('lat', [minLat, maxLat]) - .andWhereBetween('lon', [minLon, maxLon]) - .andWhere( - 'updated', - '>', - Date.now() / 1000 - portalUpdateLimit * 60 * 60 * 24, - ) + applyManualIdFilter(query, { + manualId: args.filters.onlyManualId, + latColumn: 'lat', + lonColumn: 'lon', + idColumn: 'id', + bounds: { minLat, maxLat, minLon, maxLon }, + }) + query.andWhere( + 'updated', + '>', + Date.now() / 1000 - portalUpdateLimit * 60 * 60 * 24, + ) if (!getAreaSql(query, areaRestrictions, onlyAreas)) { return [] } diff --git a/server/src/models/Route.js b/server/src/models/Route.js index e4d696a40..a78c1e556 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -4,6 +4,7 @@ const config = require('@rm/config') const { getAreaSql } = require('../utils/getAreaSql') const { getEpoch } = require('../utils/getClientTime') +const { applyManualIdFilter } = require('../utils/manualFilter') const GET_ALL_SELECT = /** @type {const} */ ([ 'id', @@ -44,17 +45,29 @@ class Route extends Model { const ts = getEpoch() - config.getSafe('api.routeUpdateLimit') * 24 * 60 * 60 const distanceInMeters = (onlyDistance || [0.5, 100]).map((x) => x * 1000) - const startLatitude = isMad ? 'start_poi_latitude' : 'start_lat' const startLongitude = isMad ? 'start_poi_longitude' : 'start_lon' const distanceMeters = isMad ? 'route_distance_meters' : 'distance_meters' const endLatitude = isMad ? 'end_poi_latitude' : 'end_lat' const endLongitude = isMad ? 'end_poi_longitude' : 'end_lon' - const query = this.query() - .select(isMad ? GET_MAD_ALL_SELECT : GET_ALL_SELECT) - .whereBetween(startLatitude, [args.minLat, args.maxLat]) - .andWhereBetween(startLongitude, [args.minLon, args.maxLon]) + const idColumn = isMad ? 'route_id' : 'id' + const query = this.query().select( + isMad ? GET_MAD_ALL_SELECT : GET_ALL_SELECT, + ) + const manualId = applyManualIdFilter(query, { + manualId: args.filters.onlyManualId, + latColumn: startLatitude, + lonColumn: startLongitude, + idColumn, + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) + query .andWhereBetween(distanceMeters, distanceInMeters) .andWhere((builder) => { builder.where( @@ -68,9 +81,19 @@ class Route extends Model { }) .union((qb) => { qb.select(isMad ? GET_MAD_ALL_SELECT : GET_ALL_SELECT) - .whereBetween(endLatitude, [args.minLat, args.maxLat]) - .andWhereBetween(endLongitude, [args.minLon, args.maxLon]) - .andWhereBetween(distanceMeters, distanceInMeters) + applyManualIdFilter(qb, { + manualId, + latColumn: endLatitude, + lonColumn: endLongitude, + idColumn, + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) + qb.andWhereBetween(distanceMeters, distanceInMeters) .andWhere((builder) => { builder.where( isMad ? raw('UNIX_TIMESTAMP(last_updated)') : 'updated', diff --git a/server/src/models/Station.js b/server/src/models/Station.js index 46172f0ab..7f3078f60 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -4,6 +4,7 @@ const config = require('@rm/config') const i18next = require('i18next') const { getAreaSql } = require('../utils/getAreaSql') +const { applyManualIdFilter } = require('../utils/manualFilter') const { getEpoch } = require('../utils/getClientTime') const { state } = require('../services/state') @@ -30,7 +31,6 @@ class Station extends Model { onlyGmaxStationed, } = args.filters const ts = getEpoch() - const select = [ 'id', 'name', @@ -42,8 +42,19 @@ class Station extends Model { ] const query = this.query() - .whereBetween('lat', [args.minLat, args.maxLat]) - .andWhereBetween('lon', [args.minLon, args.maxLon]) + applyManualIdFilter(query, { + manualId: args.filters.onlyManualId, + latColumn: 'lat', + lonColumn: 'lon', + idColumn: 'id', + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) + query .andWhere('end_time', '>', ts) .andWhere( 'updated', diff --git a/server/src/utils/manualFilter.js b/server/src/utils/manualFilter.js new file mode 100644 index 000000000..120b4807a --- /dev/null +++ b/server/src/utils/manualFilter.js @@ -0,0 +1,57 @@ +// @ts-check + +/** + * @param {unknown} manualId + * @returns {string | number | null} + */ +function normalizeManualId(manualId) { + if ( + manualId === undefined || + manualId === null || + manualId === '' || + (typeof manualId !== 'string' && typeof manualId !== 'number') + ) { + return null + } + return manualId +} + +/** + * @param {import('objection').QueryBuilder} query + * @param {{ + * manualId?: string | number | null, + * latColumn: string, + * lonColumn: string, + * idColumn: string, + * bounds: { minLat: number, maxLat: number, minLon: number, maxLon: number }, + * }} options + * @returns {string | number | null} + */ +function applyManualIdFilter(query, options) { + const { + manualId: rawManual, + latColumn, + lonColumn, + idColumn, + bounds, + } = options + + const manualId = normalizeManualId(rawManual) + + if (manualId !== null) { + query.where((builder) => { + builder + .whereBetween(latColumn, [bounds.minLat, bounds.maxLat]) + .andWhereBetween(lonColumn, [bounds.minLon, bounds.maxLon]) + .orWhere(idColumn, manualId) + }) + } else { + query + .whereBetween(latColumn, [bounds.minLat, bounds.maxLat]) + .andWhereBetween(lonColumn, [bounds.minLon, bounds.maxLon]) + } + + return manualId +} + +module.exports = { applyManualIdFilter, normalizeManualId } diff --git a/src/features/gym/GymTile.jsx b/src/features/gym/GymTile.jsx index 139731905..cdf06e129 100644 --- a/src/features/gym/GymTile.jsx +++ b/src/features/gym/GymTile.jsx @@ -9,6 +9,7 @@ import { basicEqualFn, useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { useOpacity } from '@hooks/useOpacity' import { useForcePopup } from '@hooks/useForcePopup' +import { useManualPopupTracker } from '@hooks/useManualPopupTracker' import { sendNotification } from '@services/desktopNotification' import { TooltipWrapper } from '@components/ToolTipWrapper' import { getTimeUntil } from '@utils/getTimeUntil' @@ -155,8 +156,10 @@ const BaseGymTile = (gym) => { useForcePopup(gym.id, markerRef) useMarkerTimer(timerToDisplay, markerRef, () => setStateChange(!stateChange)) + const handlePopupOpen = useManualPopupTracker('gyms', gym.id) if (hasRaid) { sendNotification(`${gym.id}-${hasHatched}`, gym.name, 'raids', { + manualId: gym.id, lat: gym.lat, lon: gym.lon, expire: timerToDisplay, @@ -193,6 +196,7 @@ const BaseGymTile = (gym) => { selectPoi(gym.id, gym.lat, gym.lon) } }, + popupopen: handlePopupOpen, }} > diff --git a/src/features/nest/NestTile.jsx b/src/features/nest/NestTile.jsx index 69bc13f1b..a6ec1ee41 100644 --- a/src/features/nest/NestTile.jsx +++ b/src/features/nest/NestTile.jsx @@ -7,6 +7,7 @@ import { GeoJSON, Marker, Popup } from 'react-leaflet' import { basicEqualFn, useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { useForcePopup } from '@hooks/useForcePopup' +import { useManualPopupTracker } from '@hooks/useManualPopupTracker' import { nestMarker } from './nestMarker' import { NestPopup } from './NestPopup' @@ -19,6 +20,7 @@ import { NestPopup } from './NestPopup' const BaseNestTile = (nest) => { const recent = Date.now() / 1000 - nest.updated < 172800000 const internalId = `${nest.pokemon_id}-${nest.pokemon_form}` + const handlePopupOpen = useManualPopupTracker('nests', nest.id) const size = useStorage( (s) => s.filters.nests.filter[internalId]?.size || 'md', @@ -39,13 +41,17 @@ const BaseNestTile = (nest) => { iconSize={iconSize} recent={recent} nest={nest} + eventHandlers={{ popupopen: handlePopupOpen }} > )} - + @@ -107,10 +113,10 @@ const NestMarker = ({ /** * - * @param {{ polygon_path: string, children?: React.ReactNode }} props + * @param {{ polygon_path: string, children?: React.ReactNode, eventHandlers?: import('leaflet').LeafletEventHandlerFnMap }} props * @returns */ -const NestGeoJSON = ({ polygon_path, children }) => { +const NestGeoJSON = ({ polygon_path, eventHandlers, children }) => { const showPolygons = useStorage((s) => s.filters.nests.polygons) const geometry = React.useMemo(() => { @@ -126,7 +132,11 @@ const NestGeoJSON = ({ polygon_path, children }) => { if (!showPolygons) { return null } - return {children} + return ( + + {children} + + ) } export const NestTile = React.memo( diff --git a/src/features/pokemon/PokemonTile.jsx b/src/features/pokemon/PokemonTile.jsx index 18d4772aa..530b128bc 100644 --- a/src/features/pokemon/PokemonTile.jsx +++ b/src/features/pokemon/PokemonTile.jsx @@ -11,6 +11,7 @@ import { basicEqualFn, useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { useOpacity } from '@hooks/useOpacity' import { useForcePopup } from '@hooks/useForcePopup' +import { useManualPopupTracker } from '@hooks/useManualPopupTracker' import { sendNotification } from '@services/desktopNotification' import { useMapStore } from '@store/useMapStore' import { TooltipWrapper } from '@components/ToolTipWrapper' @@ -138,6 +139,7 @@ const BasePokemonTile = (pkmn) => { useForcePopup(pkmn.id, markerRef) useMarkerTimer(pkmn.expire_timestamp, markerRef) + const handlePopupOpen = useManualPopupTracker('pokemon', pkmn.id) sendNotification( pkmn.id, `${t(`poke_${pkmn.pokemon_id}`)}${ @@ -188,6 +190,7 @@ const BasePokemonTile = (pkmn) => { }) : basicPokemonMarker({ iconUrl, iconSize }) } + eventHandlers={{ popupopen: handlePopupOpen }} > diff --git a/src/features/pokestop/PokestopTile.jsx b/src/features/pokestop/PokestopTile.jsx index 6db1bc190..8e64e49a7 100644 --- a/src/features/pokestop/PokestopTile.jsx +++ b/src/features/pokestop/PokestopTile.jsx @@ -8,6 +8,7 @@ import { basicEqualFn, useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { useRouteStore, resolveRoutePoiKey } from '@features/route' import { useForcePopup } from '@hooks/useForcePopup' +import { useManualPopupTracker } from '@hooks/useManualPopupTracker' import { TooltipWrapper } from '@components/ToolTipWrapper' import { PokestopPopup } from './PokestopPopup' @@ -130,6 +131,7 @@ const BasePokestopTile = (pokestop) => { useMarkerTimer(timers.length ? Math.min(...timers) : null, markerRef, () => setStateChange(!stateChange), ) + const handlePopupOpen = useManualPopupTracker('pokestops', pokestop.id) const icon = usePokestopMarker({ hasQuest, @@ -150,6 +152,7 @@ const BasePokestopTile = (pokestop) => { selectPoi(pokestop.id, pokestop.lat, pokestop.lon) } }, + popupopen: handlePopupOpen, }} > diff --git a/src/features/portal/PortalTile.jsx b/src/features/portal/PortalTile.jsx index ac19de2f2..dcb64595e 100644 --- a/src/features/portal/PortalTile.jsx +++ b/src/features/portal/PortalTile.jsx @@ -5,6 +5,7 @@ import { Circle, Popup } from 'react-leaflet' import { useStorage } from '@store/useStorage' import { useForcePopup } from '@hooks/useForcePopup' +import { useManualPopupTracker } from '@hooks/useManualPopupTracker' import { PortalPopup } from './PortalPopup' @@ -22,6 +23,7 @@ const BasePortalTile = (portal) => { ) useForcePopup(portal.id, markerRef) + const handlePopupOpen = useManualPopupTracker('portals', portal.id) return ( { fillOpacity={0.25} color={color} fillColor={color} + eventHandlers={{ popupopen: handlePopupOpen }} > diff --git a/src/features/route/RoutePopup.jsx b/src/features/route/RoutePopup.jsx index c88732a48..09cc7b068 100644 --- a/src/features/route/RoutePopup.jsx +++ b/src/features/route/RoutePopup.jsx @@ -29,6 +29,7 @@ import { Title } from '@components/popups/Title' import { Timer } from '@components/popups/Timer' import { Navigation } from '@components/popups/Navigation' import { Notification } from '@components/Notification' +import { useManualPopupTracker } from '@hooks/useManualPopupTracker' import { useFormatDistance } from './useFormatDistance' @@ -370,6 +371,8 @@ export function RoutePopup({ end, inline = false, ...props }) { return content } + const handlePopupOpen = useManualPopupTracker('routes', props.id) + return ( { @@ -377,6 +380,7 @@ export function RoutePopup({ end, inline = false, ...props }) { getRoute() } }} + eventHandlers={{ popupopen: handlePopupOpen }} > {content} diff --git a/src/features/route/RouteTile.jsx b/src/features/route/RouteTile.jsx index dd95c6297..f7a63a93c 100644 --- a/src/features/route/RouteTile.jsx +++ b/src/features/route/RouteTile.jsx @@ -6,6 +6,7 @@ import 'leaflet-arrowheads' import { darken } from '@mui/material/styles' import { useForcePopup } from '@hooks/useForcePopup' +import { useManualPopupTracker } from '@hooks/useManualPopupTracker' import { routeMarker } from './routeMarker' import { ROUTE_MARKER_PANE } from './constants' @@ -105,6 +106,7 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { }, }) useForcePopup(displayRoute.id, markerRef) + const handleRoutePopupOpen = useManualPopupTracker('routes', displayRoute.id) React.useEffect(() => { setLinePopup(null) @@ -194,7 +196,10 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { } pane={ROUTE_MARKER_PANE} eventHandlers={{ - popupopen: () => setClicked(true), + popupopen: () => { + setClicked(true) + handleRoutePopupOpen() + }, popupclose: () => setClicked(false), mouseover: () => { if (lineRef.current) { @@ -280,6 +285,7 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { eventHandlers={{ remove: () => setLinePopup(null), close: () => setLinePopup(null), + popupopen: handleRoutePopupOpen, }} > diff --git a/src/features/station/StationTile.jsx b/src/features/station/StationTile.jsx index b0638cdae..5da5464a2 100644 --- a/src/features/station/StationTile.jsx +++ b/src/features/station/StationTile.jsx @@ -7,6 +7,7 @@ import { useMarkerTimer } from '@hooks/useMarkerTimer' import { basicEqualFn, useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { useForcePopup } from '@hooks/useForcePopup' +import { useManualPopupTracker } from '@hooks/useManualPopupTracker' import { TooltipWrapper } from '@components/ToolTipWrapper' import { StationPopup } from './StationPopup' @@ -52,12 +53,14 @@ const BaseStationTile = (station) => { useMarkerTimer(timers.length ? Math.min(...timers) : null, markerRef, () => setStateChange(!stateChange), ) + const handlePopupOpen = useManualPopupTracker('stations', station.id) return ( diff --git a/src/hooks/useManualPopupTracker.js b/src/hooks/useManualPopupTracker.js new file mode 100644 index 000000000..79c203c0d --- /dev/null +++ b/src/hooks/useManualPopupTracker.js @@ -0,0 +1,30 @@ +// @ts-check +import { useCallback } from 'react' + +import { useMemory } from '@store/useMemory' +import { normalizeCategory } from '@utils/normalizeCategory' + +/** + * Provide a stable handler that keeps manual popup tracking in sync with UI. + * + * @param {string} category + * @param {string | number} id + * @returns {() => void} + */ +export function useManualPopupTracker(category, id) { + const normalizedCategory = normalizeCategory(category) + return useCallback(() => { + if (id === undefined || id === null || id === '') return + const { manualParams } = useMemory.getState() + const currentCategory = normalizeCategory(manualParams.category) + if (manualParams.id === id && currentCategory === normalizedCategory) { + return + } + useMemory.setState({ + manualParams: { + category, + id, + }, + }) + }, [category, id, normalizedCategory]) +} diff --git a/src/pages/map/components/Clustering.jsx b/src/pages/map/components/Clustering.jsx index 46c36f975..6376074e1 100644 --- a/src/pages/map/components/Clustering.jsx +++ b/src/pages/map/components/Clustering.jsx @@ -7,6 +7,7 @@ import { marker, divIcon, point } from 'leaflet' import { useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { Notification } from '@components/Notification' +import { normalizeCategory } from '@utils/normalizeCategory' const IGNORE_CLUSTERING = new Set([ 'devices', @@ -50,6 +51,13 @@ export function Clustering({ category, children }) { const userCluster = useStorage( (s) => s.userSettings[category]?.clustering || false, ) + const manualParams = useMemory((s) => s.manualParams) + const manualKey = React.useMemo(() => { + const normalized = normalizeCategory(manualParams.category) + return normalized === category && manualParams.id !== undefined + ? `${manualParams.id}` + : null + }, [manualParams, category]) const { config: { clustering, @@ -133,6 +141,7 @@ export function Clustering({ category, children }) { newMarkers.add(cluster.id) } } + if (manualKey) newMarkers.add(manualKey) // @ts-ignore featureRef?.current?.addData(newClusters) setMarkers(newMarkers) @@ -144,12 +153,25 @@ export function Clustering({ category, children }) { } }, [children, featureRef, superCluster]) + const clustered = React.useMemo(() => { + const requiresFilter = children.length > rules.forcedLimit || userCluster + if (!requiresFilter) return children + const base = children.filter((x) => x && markers.has(x.key)) + if (manualKey) { + const manualChild = children.find( + (child) => child && child.key === manualKey, + ) + if (manualChild && !base.includes(manualChild)) { + return [...base, manualChild] + } + } + return base + }, [children, markers, manualKey, rules.forcedLimit, userCluster]) + return ( <> - {children.length > rules.forcedLimit || userCluster - ? children.filter((x) => x && markers.has(x.key)) - : children} + {clustered} {limitHit && ( { } } +const MANUAL_ID_CATEGORIES = new Set([ + 'gyms', + 'pokestops', + 'pokemon', + 'nests', + 'portals', + 'stations', + 'routes', +]) + /** * @template {keyof import('@rm/types').AllFilters} T * @param {import('@rm/types').AllFilters[T]} requestedFilters @@ -105,6 +116,7 @@ function QueryData({ category, timeout }) { const hideList = useMemory((s) => s.hideList) const active = useMemory((s) => s.active) + const manualParams = useMemory((s) => s.manualParams) const userSettings = useStorage( (s) => s.userSettings[userSettingsCategory(category)], @@ -116,13 +128,40 @@ function QueryData({ category, timeout }) { s.filters?.scanAreas?.filter?.areas, ) - const initial = React.useMemo( - () => ({ - ...getQueryArgs(), - filters: trimFilters(filters, userSettings, category, onlyAreas), - }), - [], + const normalizedCategory = React.useMemo( + () => normalizeCategory(category), + [category], ) + const manualId = React.useMemo(() => { + if ( + manualParams?.id === undefined || + manualParams.id === '' || + manualParams.id === null + ) { + return undefined + } + const manualCategory = normalizeCategory(manualParams.category) + return manualCategory === normalizedCategory ? manualParams.id : undefined + }, [manualParams, normalizedCategory]) + + const buildVariables = React.useCallback(() => { + const args = getQueryArgs() + const trimmedFilters = trimFilters( + filters, + userSettings, + category, + onlyAreas, + ) + if (manualId && MANUAL_ID_CATEGORIES.has(normalizedCategory)) { + trimmedFilters.onlyManualId = manualId + } + return { + ...args, + filters: trimmedFilters, + } + }, [filters, userSettings, category, onlyAreas, manualId, normalizedCategory]) + + const initial = React.useMemo(() => buildVariables(), [buildVariables]) const { data, previousData, error, refetch } = useQuery( Query[category](filters), { @@ -151,10 +190,7 @@ function QueryData({ category, timeout }) { React.useEffect(() => { const refetchData = () => { if (category !== 'scanAreas') { - timeout.current.doRefetch({ - ...getQueryArgs(), - filters: trimFilters(filters, userSettings, category, onlyAreas), - }) + timeout.current.doRefetch(buildVariables()) } } map.on('fetchdata', refetchData) @@ -162,7 +198,7 @@ function QueryData({ category, timeout }) { return () => { map.off('fetchdata', refetchData) } - }, [filters, userSettings, onlyAreas, timeout.current.refetch]) + }, [category, map, buildVariables, timeout]) const errorState = useProcessError(error) diff --git a/src/services/desktopNotification.js b/src/services/desktopNotification.js index f11203891..31e0d952f 100644 --- a/src/services/desktopNotification.js +++ b/src/services/desktopNotification.js @@ -42,7 +42,7 @@ export function sendNotification(key, title, category, options) { ) if (userSettings.enabled && userSettings[category]) { if (getPermission() === 'granted') { - const { lat, lon, audio, expire, ...rest } = options + const { lat, lon, audio, expire, manualId, ...rest } = options || {} const countdown = expire ? expire * 1000 - Date.now() : 1 if (countdown < 0) return cache.set(key, countdown) @@ -65,7 +65,12 @@ export function sendNotification(key, title, category, options) { } if (lat && lon) { const { map } = useMapStore.getState() - useMemory.setState({ manualParams: { category, id: key } }) + useMemory.setState({ + manualParams: { + category, + id: manualId ?? key, + }, + }) map.flyTo([lat, lon], 16) } notif.close() diff --git a/src/utils/normalizeCategory.js b/src/utils/normalizeCategory.js new file mode 100644 index 000000000..2841e327d --- /dev/null +++ b/src/utils/normalizeCategory.js @@ -0,0 +1,21 @@ +// @ts-check + +/** + * Normalize category identifiers so data and UI share consistent lookups. + * + * @param {string} category + * @returns {string} + */ +export function normalizeCategory(category) { + if (!category) return '' + const lower = category.toLowerCase() + switch (lower) { + case 'raids': + return 'gyms' + case 'lures': + case 'invasions': + return 'pokestops' + default: + return lower + } +}