diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SignatureView/SignatureView.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SignatureView/SignatureView.tsx index 030f4029..9a53de48 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SignatureView/SignatureView.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SignatureView/SignatureView.tsx @@ -10,7 +10,7 @@ export interface SignatureViewProps { export const SignatureView = ({ signature, showCharacterPortrait = false }: SignatureViewProps) => { const isWormhole = signature?.group === SignatureGroup.Wormhole; const hasCharacterInfo = showCharacterPortrait && signature.character_eve_id; - const groupDisplay = isWormhole ? SignatureGroup.Wormhole : (signature?.group ?? SignatureGroup.CosmicSignature); + const groupDisplay = isWormhole ? SignatureGroup.Wormhole : signature?.group ?? SignatureGroup.CosmicSignature; const characterName = signature.character_name || 'Unknown character'; return ( diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignatureHeader/SystemSignatureHeader.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignatureHeader/SystemSignatureHeader.tsx index 12f0c602..4a0e657b 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignatureHeader/SystemSignatureHeader.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignatureHeader/SystemSignatureHeader.tsx @@ -19,7 +19,7 @@ export type HeaderProps = { lazyDeleteValue: boolean; onLazyDeleteChange: (checked: boolean) => void; pendingCount: number; - pendingTimeRemaining?: number; // Time remaining in ms + undoCountdown?: number; onUndoClick: () => void; onSettingsClick: () => void; }; @@ -29,7 +29,7 @@ export const SystemSignaturesHeader = ({ lazyDeleteValue, onLazyDeleteChange, pendingCount, - pendingTimeRemaining, + undoCountdown, onUndoClick, onSettingsClick, }: HeaderProps) => { @@ -43,13 +43,6 @@ export const SystemSignaturesHeader = ({ const containerRef = useRef(null); const isCompact = useMaxWidth(containerRef, COMPACT_MAX_WIDTH); - // Format time remaining as seconds - const formatTimeRemaining = () => { - if (!pendingTimeRemaining) return ''; - const seconds = Math.ceil(pendingTimeRemaining / 1000); - return ` (${seconds}s remaining)`; - }; - return (
@@ -78,7 +71,9 @@ export const SystemSignaturesHeader = ({ 0 ? ` — ${undoCountdown}s left` : ''}`, + }} onClick={onUndoClick} /> )} diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignatures.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignatures.tsx index 83fd8f93..5518f9a3 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignatures.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignatures.tsx @@ -1,99 +1,152 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useState, useEffect, useRef, useMemo } from 'react'; import { Widget } from '@/hooks/Mapper/components/mapInterface/components'; import { SystemSignaturesContent } from './SystemSignaturesContent'; import { SystemSignatureSettingsDialog } from './SystemSignatureSettingsDialog'; -import { ExtendedSystemSignature, SystemSignature } from '@/hooks/Mapper/types'; import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; -import { useHotkey } from '@/hooks/Mapper/hooks'; import { SystemSignaturesHeader } from './SystemSignatureHeader'; import useLocalStorageState from 'use-local-storage-state'; +import { useHotkey } from '@/hooks/Mapper/hooks/useHotkey'; import { SETTINGS_KEYS, SETTINGS_VALUES, - SIGNATURE_DELETION_TIMEOUTS, SIGNATURE_SETTING_STORE_KEY, SIGNATURE_WINDOW_ID, - SIGNATURES_DELETION_TIMING, SignatureSettingsType, + SIGNATURES_DELETION_TIMING, + SIGNATURE_DELETION_TIMEOUTS, } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/constants.ts'; -import { calculateTimeRemaining } from './helpers'; +import { OutCommand, OutCommandHandler } from '@/hooks/Mapper/types/mapHandlers'; -export const SystemSignatures = () => { - const [visible, setVisible] = useState(false); - const [sigCount, setSigCount] = useState(0); - const [pendingSigs, setPendingSigs] = useState([]); - const [pendingTimeRemaining, setPendingTimeRemaining] = useState(); - const undoPendingFnRef = useRef<() => void>(() => {}); +/** + * Custom hook for managing pending signature deletions and undo countdown. + */ +function useSignatureUndo( + systemId: string | undefined, + settings: SignatureSettingsType, + outCommand: OutCommandHandler, +) { + const [pendingIds, setPendingIds] = useState([]); + const [countdown, setCountdown] = useState(0); + const intervalRef = useRef(null); - const { - data: { selectedSystems }, - } = useMapRootState(); - - const [currentSettings, setCurrentSettings] = useLocalStorageState(SIGNATURE_SETTING_STORE_KEY, { - defaultValue: SETTINGS_VALUES, - }); - - const handleSigCountChange = useCallback((count: number) => { - setSigCount(count); + const addDeleted = useCallback((ids: string[]) => { + setPendingIds(prev => [...prev, ...ids]); }, []); - const [systemId] = selectedSystems; - const isNotSelectedSystem = selectedSystems.length !== 1; - - const handleSettingsChange = useCallback((newSettings: SignatureSettingsType) => { - setCurrentSettings(newSettings); - setVisible(false); - }, []); - - const handleLazyDeleteChange = useCallback((value: boolean) => { - setCurrentSettings(prev => ({ ...prev, [SETTINGS_KEYS.LAZY_DELETE_SIGNATURES]: value })); - }, []); - - useHotkey(true, ['z'], event => { - if (pendingSigs.length > 0) { - event.preventDefault(); - event.stopPropagation(); - undoPendingFnRef.current(); - setPendingSigs([]); - setPendingTimeRemaining(undefined); - } - }); - - const handleUndoClick = useCallback(() => { - undoPendingFnRef.current(); - setPendingSigs([]); - setPendingTimeRemaining(undefined); - }, []); - - const handleSettingsButtonClick = useCallback(() => { - setVisible(true); - }, []); - - const handlePendingChange = useCallback( - (pending: React.MutableRefObject>, newUndo: () => void) => { - setPendingSigs(() => { - return Object.values(pending.current).filter(sig => sig.pendingDeletion); - }); - undoPendingFnRef.current = newUndo; - }, - [], - ); - - // Calculate the minimum time remaining for any pending signature + // kick off or clear countdown whenever pendingIds changes useEffect(() => { - if (pendingSigs.length === 0) { - setPendingTimeRemaining(undefined); + // clear any existing timer + if (intervalRef.current != null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + if (pendingIds.length === 0) { + setCountdown(0); return; } - const calculate = () => { - setPendingTimeRemaining(() => calculateTimeRemaining(pendingSigs)); - }; + // determine timeout from settings + const timingKey = Number(settings[SETTINGS_KEYS.DELETION_TIMING] ?? SIGNATURES_DELETION_TIMING.DEFAULT); + const timeoutMs = + Number(SIGNATURE_DELETION_TIMEOUTS[timingKey as keyof typeof SIGNATURE_DELETION_TIMEOUTS]) || 10000; + setCountdown(Math.ceil(timeoutMs / 1000)); - calculate(); - const interval = setInterval(calculate, 1000); - return () => clearInterval(interval); - }, [pendingSigs]); + // start new interval + intervalRef.current = window.setInterval(() => { + setCountdown(prev => { + if (prev <= 1) { + clearInterval(intervalRef.current!); + intervalRef.current = null; + setPendingIds([]); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => { + if (intervalRef.current != null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [pendingIds, settings]); + + // undo handler + const handleUndo = useCallback(async () => { + if (!systemId || pendingIds.length === 0) return; + await outCommand({ + type: OutCommand.undoDeleteSignatures, + data: { system_id: systemId, eve_ids: pendingIds }, + }); + setPendingIds([]); + setCountdown(0); + if (intervalRef.current != null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, [systemId, pendingIds, outCommand]); + + return { + pendingIds, + countdown, + addDeleted, + handleUndo, + }; +} + +export const SystemSignatures = () => { + const [visible, setVisible] = useState(false); + const [sigCount, setSigCount] = useState(0); + + const { + data: { selectedSystems }, + outCommand, + } = useMapRootState(); + + const [currentSettings, setCurrentSettings] = useLocalStorageState( + SIGNATURE_SETTING_STORE_KEY, + { + defaultValue: SETTINGS_VALUES, + }, + ); + + const [systemId] = selectedSystems; + const isSystemSelected = useMemo(() => selectedSystems.length === 1, [selectedSystems.length]); + const { pendingIds, countdown, addDeleted, handleUndo } = useSignatureUndo(systemId, currentSettings, outCommand); + + useHotkey(true, ['z', 'Z'], (event: KeyboardEvent) => { + if (pendingIds.length > 0 && countdown > 0) { + event.preventDefault(); + event.stopPropagation(); + handleUndo(); + } + }); + + const handleCountChange = useCallback((count: number) => { + setSigCount(count); + }, []); + + const handleSettingsSave = useCallback( + (newSettings: SignatureSettingsType) => { + setCurrentSettings(newSettings); + setVisible(false); + }, + [setCurrentSettings], + ); + + const handleLazyDeleteToggle = useCallback( + (value: boolean) => { + setCurrentSettings(prev => ({ + ...prev, + [SETTINGS_KEYS.LAZY_DELETE_SIGNATURES]: value, + })); + }, + [setCurrentSettings], + ); + + const openSettings = useCallback(() => setVisible(true), []); return ( { } windowId={SIGNATURE_WINDOW_ID} > - {isNotSelectedSystem ? ( + {!isSystemSelected ? (
System is not selected
@@ -118,22 +171,17 @@ export const SystemSignatures = () => { )} + {visible && ( setVisible(false)} - onSave={handleSettingsChange} + onSave={handleSettingsSave} /> )}
diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignaturesContent/SystemSignaturesContent.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignaturesContent/SystemSignaturesContent.tsx index 9197c015..d0e18f25 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignaturesContent/SystemSignaturesContent.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignaturesContent/SystemSignaturesContent.tsx @@ -57,12 +57,8 @@ interface SystemSignaturesContentProps { onSelect?: (signature: SystemSignature) => void; onLazyDeleteChange?: (value: boolean) => void; onCountChange?: (count: number) => void; - onPendingChange?: ( - pending: React.MutableRefObject>, - undo: () => void, - ) => void; - deletionTiming?: number; filterSignature?: (signature: SystemSignature) => boolean; + onSignatureDeleted?: (deletedIds: string[]) => void; } export const SystemSignaturesContent = ({ @@ -73,9 +69,8 @@ export const SystemSignaturesContent = ({ onSelect, onLazyDeleteChange, onCountChange, - onPendingChange, - deletionTiming, filterSignature, + onSignatureDeleted, }: SystemSignaturesContentProps) => { const [selectedSignatureForDialog, setSelectedSignatureForDialog] = useState(null); const [showSignatureSettings, setShowSignatureSettings] = useState(false); @@ -100,9 +95,8 @@ export const SystemSignaturesContent = ({ systemId, settings, onCountChange, - onPendingChange, onLazyDeleteChange, - deletionTiming, + onSignatureDeleted, }); useEffect(() => { @@ -125,6 +119,10 @@ export const SystemSignaturesContent = ({ event.preventDefault(); event.stopPropagation(); + if (onSignatureDeleted && selectedSignatures.length > 0) { + const deletedIds = selectedSignatures.map(s => s.eve_id); + onSignatureDeleted(deletedIds); + } handleDeleteSelected(); }); @@ -155,7 +153,7 @@ export const SystemSignaturesContent = ({ (e: { value: SystemSignature[] }) => { selectable ? onSelect?.(e.value[0]) : setSelectedSignatures(e.value as ExtendedSystemSignature[]); }, - [selectable], + [onSelect, selectable, setSelectedSignatures], ); const { showDescriptionColumn, showUpdatedColumn, showCharacterColumn, showCharacterPortrait } = useMemo( diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/hooks/usePendingDeletions.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/hooks/usePendingDeletions.ts index 89f9a51b..b3d770c9 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/hooks/usePendingDeletions.ts +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/hooks/usePendingDeletions.ts @@ -1,23 +1,18 @@ -import { useCallback, useRef, useEffect } from 'react'; +import { useCallback, useRef } from 'react'; import { OutCommand } from '@/hooks/Mapper/types/mapHandlers'; -import { prepareUpdatePayload, scheduleLazyTimers } from '../helpers'; +import { prepareUpdatePayload } from '../helpers'; import { UsePendingDeletionParams } from './types'; -import { FINAL_DURATION_MS } from '../constants'; import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; import { ExtendedSystemSignature } from '@/hooks/Mapper/types'; export function usePendingDeletions({ systemId, setSignatures, - deletionTiming, onPendingChange, -}: UsePendingDeletionParams) { +}: Omit) { const { outCommand } = useMapRootState(); const pendingDeletionMapRef = useRef>({}); - // Use the provided deletion timing or fall back to the default - const finalDuration = deletionTiming !== undefined ? deletionTiming : FINAL_DURATION_MS; - const processRemovedSignatures = useCallback( async ( removed: ExtendedSystemSignature[], @@ -25,63 +20,15 @@ export function usePendingDeletions({ updated: ExtendedSystemSignature[], ) => { if (!removed.length) return; - - // If deletion timing is 0, immediately delete without pending state - if (finalDuration === 0) { - await outCommand({ - type: OutCommand.updateSignatures, - data: prepareUpdatePayload(systemId, added, updated, removed), - }); - return; - } - - const now = Date.now(); - const processedRemoved = removed.map(r => ({ - ...r, - pendingDeletion: true, - pendingUntil: now + finalDuration, - })); - pendingDeletionMapRef.current = { - ...pendingDeletionMapRef.current, - ...processedRemoved.reduce((acc: any, sig) => { - acc[sig.eve_id] = sig; - return acc; - }, {}), - }; - - onPendingChange?.(pendingDeletionMapRef, clearPendingDeletions); - - setSignatures(prev => - prev.map(sig => { - if (processedRemoved.find(r => r.eve_id === sig.eve_id)) { - return { ...sig, pendingDeletion: true, pendingUntil: now + finalDuration }; - } - return sig; - }), - ); - - scheduleLazyTimers( - processedRemoved, - pendingDeletionMapRef, - async sig => { - await outCommand({ - type: OutCommand.updateSignatures, - data: prepareUpdatePayload(systemId, [], [], [sig]), - }); - delete pendingDeletionMapRef.current[sig.eve_id]; - setSignatures(prev => prev.filter(x => x.eve_id !== sig.eve_id)); - onPendingChange?.(pendingDeletionMapRef, clearPendingDeletions); - }, - finalDuration, - ); + await outCommand({ + type: OutCommand.updateSignatures, + data: prepareUpdatePayload(systemId, added, updated, removed), + }); }, - [systemId, outCommand, finalDuration], + [systemId, outCommand], ); const clearPendingDeletions = useCallback(() => { - Object.values(pendingDeletionMapRef.current).forEach(({ finalTimeoutId }) => { - clearTimeout(finalTimeoutId); - }); pendingDeletionMapRef.current = {}; setSignatures(prev => prev.map(x => (x.pendingDeletion ? { ...x, pendingDeletion: false } : x))); onPendingChange?.(pendingDeletionMapRef, clearPendingDeletions); diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/hooks/useSystemSignaturesData.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/hooks/useSystemSignaturesData.ts index 0e2b3295..277a8c82 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/hooks/useSystemSignaturesData.ts +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/hooks/useSystemSignaturesData.ts @@ -18,8 +18,8 @@ export const useSystemSignaturesData = ({ onCountChange, onPendingChange, onLazyDeleteChange, - deletionTiming, -}: UseSystemSignaturesDataProps) => { + onSignatureDeleted, +}: Omit & { onSignatureDeleted?: (deletedIds: string[]) => void }) => { const { outCommand } = useMapRootState(); const [signatures, setSignatures, signaturesRef] = useRefState([]); const [selectedSignatures, setSelectedSignatures] = useState([]); @@ -27,7 +27,6 @@ export const useSystemSignaturesData = ({ const { pendingDeletionMapRef, processRemovedSignatures, clearPendingDeletions } = usePendingDeletions({ systemId, setSignatures, - deletionTiming, onPendingChange, }); @@ -59,6 +58,10 @@ export const useSystemSignaturesData = ({ if (removed.length > 0) { await processRemovedSignatures(removed, added, updated); + if (onSignatureDeleted) { + const deletedIds = removed.map(sig => sig.eve_id); + onSignatureDeleted(deletedIds); + } } if (updated.length !== 0 || added.length !== 0) { @@ -78,17 +81,16 @@ export const useSystemSignaturesData = ({ onLazyDeleteChange?.(false); } }, - [settings, signaturesRef, processRemovedSignatures, outCommand, systemId, onLazyDeleteChange], + [settings, signaturesRef, processRemovedSignatures, outCommand, systemId, onLazyDeleteChange, onSignatureDeleted], ); const handleDeleteSelected = useCallback(async () => { if (!selectedSignatures.length) return; const selectedIds = selectedSignatures.map(s => s.eve_id); const finalList = signatures.filter(s => !selectedIds.includes(s.eve_id)); - await handleUpdateSignatures(finalList, false, true); setSelectedSignatures([]); - }, [selectedSignatures, signatures]); + }, [handleUpdateSignatures, selectedSignatures, signatures]); const handleSelectAll = useCallback(() => { setSelectedSignatures(signatures); @@ -119,7 +121,7 @@ export const useSystemSignaturesData = ({ }, [signatures]); return { - signatures, + signatures: signatures.filter(sig => !sig.deleted), selectedSignatures, setSelectedSignatures, handleDeleteSelected, diff --git a/assets/js/hooks/Mapper/types/mapHandlers.ts b/assets/js/hooks/Mapper/types/mapHandlers.ts index 1e58ddaa..d4b741f8 100644 --- a/assets/js/hooks/Mapper/types/mapHandlers.ts +++ b/assets/js/hooks/Mapper/types/mapHandlers.ts @@ -234,15 +234,12 @@ export enum OutCommand { addSystemComment = 'addSystemComment', deleteSystemComment = 'deleteSystemComment', getSystemComments = 'getSystemComments', - // toggleTrack = 'toggle_track', toggleFollow = 'toggle_follow', getCharacterInfo = 'getCharacterInfo', getCharactersTrackingInfo = 'getCharactersTrackingInfo', updateCharacterTracking = 'updateCharacterTracking', updateFollowingCharacter = 'updateFollowingCharacter', updateMainCharacter = 'updateMainCharacter', - - // Only UI commands openSettings = 'open_settings', showActivity = 'show_activity', showTracking = 'show_tracking', @@ -250,7 +247,7 @@ export enum OutCommand { updateUserSettings = 'update_user_settings', unlinkSignature = 'unlink_signature', searchSystems = 'search_systems', + undoDeleteSignatures = 'undo_delete_signatures', } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type OutCommandHandler = (event: { type: OutCommand; data: any }) => Promise; +export type OutCommandHandler = (event: { type: OutCommand; data: unknown }) => Promise; diff --git a/assets/js/hooks/Mapper/types/signatures.ts b/assets/js/hooks/Mapper/types/signatures.ts index 0b8fe38c..ca7cdfc9 100644 --- a/assets/js/hooks/Mapper/types/signatures.ts +++ b/assets/js/hooks/Mapper/types/signatures.ts @@ -30,6 +30,7 @@ export type GroupType = { export type SignatureCustomInfo = { k162Type?: string; isEOL?: boolean; + isCrit?: boolean; }; export type SystemSignature = { @@ -46,6 +47,7 @@ export type SystemSignature = { linked_system?: SolarSystemStaticInfoRaw; inserted_at?: string; updated_at?: string; + deleted?: boolean; }; export interface ExtendedSystemSignature extends SystemSignature { @@ -53,6 +55,7 @@ export interface ExtendedSystemSignature extends SystemSignature { pendingAddition?: boolean; pendingUntil?: number; finalTimeoutId?: number; + deleted?: boolean; } export enum SignatureKindENG { diff --git a/assets/static/images/news/05-08-undo/undo.png b/assets/static/images/news/05-08-undo/undo.png new file mode 100755 index 00000000..5b5a3cf1 Binary files /dev/null and b/assets/static/images/news/05-08-undo/undo.png differ diff --git a/lib/wanderer_app/api/map_system_signature.ex b/lib/wanderer_app/api/map_system_signature.ex index 6093822e..fdb9956a 100644 --- a/lib/wanderer_app/api/map_system_signature.ex +++ b/lib/wanderer_app/api/map_system_signature.ex @@ -24,7 +24,10 @@ defmodule WandererApp.Api.MapSystemSignature do ) define(:by_system_id, action: :by_system_id, args: [:system_id]) + define(:by_system_id_all, action: :by_system_id_all, args: [:system_id]) + define(:by_system_id_and_eve_ids, action: :by_system_id_and_eve_ids, args: [:system_id, :eve_ids]) define(:by_linked_system_id, action: :by_linked_system_id, args: [:linked_system_id]) + define(:by_deleted_and_updated_before!, action: :by_deleted_and_updated_before, args: [:deleted, :updated_before]) end actions do @@ -36,7 +39,8 @@ defmodule WandererApp.Api.MapSystemSignature do :description, :kind, :group, - :type + :type, + :deleted ] defaults [:read, :destroy] @@ -64,7 +68,8 @@ defmodule WandererApp.Api.MapSystemSignature do :kind, :group, :type, - :custom_info + :custom_info, + :deleted ] argument :system_id, :uuid, allow_nil?: false @@ -83,7 +88,7 @@ defmodule WandererApp.Api.MapSystemSignature do :group, :type, :custom_info, - :updated + :deleted ] primary? true @@ -105,14 +110,32 @@ defmodule WandererApp.Api.MapSystemSignature do read :by_system_id do argument(:system_id, :string, allow_nil?: false) + filter(expr(system_id == ^arg(:system_id) and deleted == false)) + end + + read :by_system_id_all do + argument(:system_id, :string, allow_nil?: false) filter(expr(system_id == ^arg(:system_id))) end + read :by_system_id_and_eve_ids do + argument(:system_id, :string, allow_nil?: false) + argument(:eve_ids, {:array, :string}, allow_nil?: false) + filter(expr(system_id == ^arg(:system_id) and eve_id in ^arg(:eve_ids))) + end + read :by_linked_system_id do argument(:linked_system_id, :integer, allow_nil?: false) filter(expr(linked_system_id == ^arg(:linked_system_id))) end + + read :by_deleted_and_updated_before do + argument(:deleted, :boolean, allow_nil?: false) + argument(:updated_before, :utc_datetime, allow_nil?: false) + + filter(expr(deleted == ^arg(:deleted) and updated_at < ^arg(:updated_before))) + end end attributes do @@ -149,7 +172,10 @@ defmodule WandererApp.Api.MapSystemSignature do allow_nil? true end - attribute :updated, :integer + attribute :deleted, :boolean do + allow_nil? false + default false + end create_timestamp(:inserted_at) update_timestamp(:updated_at) diff --git a/lib/wanderer_app/map/map_manager.ex b/lib/wanderer_app/map/map_manager.ex index 8a4dea6a..39f66019 100644 --- a/lib/wanderer_app/map/map_manager.ex +++ b/lib/wanderer_app/map/map_manager.ex @@ -9,12 +9,15 @@ defmodule WandererApp.Map.Manager do alias WandererApp.Map.Server alias WandererApp.Map.ServerSupervisor + alias WandererApp.Api.MapSystemSignature @maps_start_per_second 5 @maps_start_interval 1000 @maps_queue :maps_queue @garbage_collection_interval :timer.hours(1) @check_maps_queue_interval :timer.seconds(1) + @signatures_cleanup_interval :timer.minutes(30) + @delete_after_minutes 30 def start_map(map_id) when is_binary(map_id), do: WandererApp.Queue.push_uniq(@maps_queue, map_id) @@ -44,6 +47,9 @@ defmodule WandererApp.Map.Manager do {:ok, garbage_collector_timer} = :timer.send_interval(@garbage_collection_interval, :garbage_collect) + {:ok, signatures_cleanup_timer} = + :timer.send_interval(@signatures_cleanup_interval, :cleanup_signatures) + try do Task.async(fn -> start_last_active_maps() @@ -56,7 +62,8 @@ defmodule WandererApp.Map.Manager do {:ok, %{ garbage_collector_timer: garbage_collector_timer, - check_maps_queue_timer: check_maps_queue_timer + check_maps_queue_timer: check_maps_queue_timer, + signatures_cleanup_timer: signatures_cleanup_timer }} end @@ -118,6 +125,36 @@ defmodule WandererApp.Map.Manager do end end + @impl true + def handle_info(:cleanup_signatures, state) do + try do + cleanup_deleted_signatures() + {:noreply, state} + rescue + e -> + Logger.error("Failed to cleanup signatures: #{inspect(e)}") + {:noreply, state} + end + end + + def cleanup_deleted_signatures() do + delete_after_date = DateTime.utc_now() |> DateTime.add(-1 * @delete_after_minutes, :minute) + + case MapSystemSignature.by_deleted_and_updated_before!(true, delete_after_date) do + {:ok, deleted_signatures} -> + + Enum.each(deleted_signatures, fn sig -> + Ash.destroy!(sig) + end) + + :ok + + {:error, error} -> + Logger.error("Failed to fetch deleted signatures: #{inspect(error)}") + {:error, error} + end + end + defp start_last_active_maps() do {:ok, last_map_states} = WandererApp.Api.MapState.get_last_active( diff --git a/lib/wanderer_app/map/server/map_server_impl.ex b/lib/wanderer_app/map/server/map_server_impl.ex index d4714a3b..54c9669e 100644 --- a/lib/wanderer_app/map/server/map_server_impl.ex +++ b/lib/wanderer_app/map/server/map_server_impl.ex @@ -93,6 +93,7 @@ defmodule WandererApp.Map.Server.Impl do Process.send_after(self(), :cleanup_connections, 5_000) Process.send_after(self(), :cleanup_systems, 10_000) Process.send_after(self(), :cleanup_characters, :timer.minutes(5)) + Process.send_after(self(), :cleanup_signatures, :timer.minutes(30)) Process.send_after(self(), :backup_state, @backup_state_timeout) WandererApp.Cache.insert("map_#{map_id}:started", true) @@ -311,6 +312,11 @@ defmodule WandererApp.Map.Server.Impl do state end + def handle_event(:cleanup_signatures, state) do + Process.send_after(self(), :cleanup_signatures, :timer.minutes(30)) + state |> SignaturesImpl.cleanup_signatures() + end + def handle_event(msg, state) do Logger.warning("Unhandled event: #{inspect(msg)}") diff --git a/lib/wanderer_app/map/server/map_server_signatures_impl.ex b/lib/wanderer_app/map/server/map_server_signatures_impl.ex index bb755886..7ab3c665 100644 --- a/lib/wanderer_app/map/server/map_server_signatures_impl.ex +++ b/lib/wanderer_app/map/server/map_server_signatures_impl.ex @@ -3,147 +3,183 @@ defmodule WandererApp.Map.Server.SignaturesImpl do require Logger + alias WandererApp.Api.{MapSystem, MapSystemSignature} + alias WandererApp.Character + alias WandererApp.User.ActivityTracker alias WandererApp.Map.Server.{Impl, ConnectionsImpl, SystemsImpl} + @doc """ + Public entrypoint for updating signatures on a map system. + """ def update_signatures( - %{map_id: map_id} = state, + state = %{map_id: map_id}, %{ - solar_system_id: solar_system_id, - character_id: character_id, + solar_system_id: system_solar_id, + character_id: char_id, user_id: user_id, - delete_connection_with_sigs: delete_connection_with_sigs, - added_signatures: added_signatures, - updated_signatures: updated_signatures, - removed_signatures: removed_signatures - } = - _signatures_update + delete_connection_with_sigs: delete_conn?, + added_signatures: added_params, + updated_signatures: updated_params, + removed_signatures: removed_params + } ) - when not is_nil(character_id) do - WandererApp.Api.MapSystem.read_by_map_and_solar_system(%{ - map_id: map_id, - solar_system_id: solar_system_id - }) - |> case do - {:ok, system} -> - {:ok, %{eve_id: character_eve_id}} = WandererApp.Character.get_character(character_id) - - added_signatures = - added_signatures - |> parse_signatures(character_eve_id, system.id) - - updated_signatures = - updated_signatures - |> parse_signatures(character_eve_id, system.id) - - updated_signatures_eve_ids = - updated_signatures - |> Enum.map(fn s -> s.eve_id end) - - removed_signatures_eve_ids = - removed_signatures - |> parse_signatures(character_eve_id, system.id) - |> Enum.map(fn s -> s.eve_id end) - - WandererApp.Api.MapSystemSignature.by_system_id!(system.id) - |> Enum.filter(fn s -> s.eve_id in removed_signatures_eve_ids end) - |> Enum.each(fn s -> - if delete_connection_with_sigs && not is_nil(s.linked_system_id) do - state - |> ConnectionsImpl.delete_connection(%{ - solar_system_source_id: system.solar_system_id, - solar_system_target_id: s.linked_system_id - }) - end - - if not is_nil(s.linked_system_id) do - state - |> SystemsImpl.update_system_linked_sig_eve_id(%{ - solar_system_id: s.linked_system_id, - linked_sig_eve_id: nil - }) - end - - s - |> Ash.destroy!() - end) - - WandererApp.Api.MapSystemSignature.by_system_id!(system.id) - |> Enum.filter(fn s -> s.eve_id in updated_signatures_eve_ids end) - |> Enum.each(fn s -> - updated = updated_signatures |> Enum.find(fn u -> u.eve_id == s.eve_id end) - - if not is_nil(updated) do - s - |> WandererApp.Api.MapSystemSignature.update( - updated - |> Map.put(:updated, System.os_time()) - ) - end - end) - - added_signatures - |> Enum.each(fn s -> - s |> WandererApp.Api.MapSystemSignature.create!() - end) - - added_signatures_eve_ids = - added_signatures - |> Enum.map(fn s -> s.eve_id end) - - if not (added_signatures_eve_ids |> Enum.empty?()) do - WandererApp.User.ActivityTracker.track_map_event(:signatures_added, %{ - character_id: character_id, - user_id: user_id, - map_id: map_id, - solar_system_id: system.solar_system_id, - signatures: added_signatures_eve_ids - }) - end - - if not (removed_signatures_eve_ids |> Enum.empty?()) do - WandererApp.User.ActivityTracker.track_map_event(:signatures_removed, %{ - character_id: character_id, - user_id: user_id, - map_id: map_id, - solar_system_id: system.solar_system_id, - signatures: removed_signatures_eve_ids - }) - end - - Impl.broadcast!(map_id, :signatures_updated, system.solar_system_id) - - state - - _ -> + when not is_nil(char_id) do + with {:ok, system} <- + MapSystem.read_by_map_and_solar_system(%{map_id: map_id, solar_system_id: system_solar_id}), + {:ok, %{eve_id: char_eve_id}} <- Character.get_character(char_id) do + do_update_signatures( + state, + system, + char_eve_id, + user_id, + delete_conn?, + added_params, + updated_params, + removed_params + ) + else + error -> + Logger.warning("Skipping signature update: #{inspect(error)}") state end end - def update_signatures( - state, - _signatures_update - ), - do: state + def update_signatures(state, _), do: state - defp parse_signatures(signatures, character_eve_id, system_id), - do: - signatures - |> Enum.map(fn %{ - "eve_id" => eve_id, - "name" => name, - "kind" => kind, - "group" => group - } = signature -> - %{ - system_id: system_id, - eve_id: eve_id, - name: name, - description: Map.get(signature, "description"), - kind: kind, - group: group, - type: Map.get(signature, "type"), - custom_info: Map.get(signature, "custom_info"), - character_eve_id: character_eve_id - } - end) + defp do_update_signatures( + state, + system, + character_eve_id, + user_id, + delete_conn?, + added_params, + updated_params, + removed_params + ) do + # parse incoming DTOs + added_sigs = parse_signatures(added_params, character_eve_id, system.id) + updated_sigs = parse_signatures(updated_params, character_eve_id, system.id) + removed_sigs = parse_signatures(removed_params, character_eve_id, system.id) + + # fetch both current & all (including deleted) signatures once + existing_current = MapSystemSignature.by_system_id!(system.id) + existing_all = MapSystemSignature.by_system_id_all!(system.id) + + removed_ids = Enum.map(removed_sigs, & &1.eve_id) + updated_ids = Enum.map(updated_sigs, & &1.eve_id) + added_ids = Enum.map(added_sigs, & &1.eve_id) + + # 1. Removals + existing_current + |> Enum.filter(&(&1.eve_id in removed_ids)) + |> Enum.each(&remove_signature(&1, state, system, delete_conn?)) + + # 2. Updates + existing_current + |> Enum.filter(&(&1.eve_id in updated_ids)) + |> Enum.each(fn existing -> + update = Enum.find(updated_sigs, &(&1.eve_id == existing.eve_id)) + apply_update_signature(existing, update) + end) + + # 3. Additions & restorations + added_eve_ids = Enum.map(added_sigs, & &1.eve_id) + existing_index = MapSystemSignature.by_system_id_all!(system.id) + |> Enum.filter(&(&1.eve_id in added_eve_ids)) + |> Map.new(&{&1.eve_id, &1}) + + added_sigs + |> Enum.each(fn sig -> + case existing_index[sig.eve_id] do + nil -> + MapSystemSignature.create!(sig) + + %MapSystemSignature{deleted: true} = deleted_sig -> + MapSystemSignature.update!( + deleted_sig, + %{sig | deleted: false} + ) + + _ -> + :noop + end + end) + + # 4. Activity tracking + if added_ids != [] do + track_activity(:signatures_added, state.map_id, system.solar_system_id, user_id, character_eve_id, + added_ids + ) + end + + if removed_ids != [] do + track_activity( + :signatures_removed, + state.map_id, + system.solar_system_id, + user_id, + character_eve_id, + removed_ids + ) + end + + # 5. Broadcast to any live subscribers + Impl.broadcast!(state.map_id, :signatures_updated, system.solar_system_id) + + state + end + + defp remove_signature(sig, state, system, delete_conn?) do + # optionally remove the linked connection + if delete_conn? && sig.linked_system_id do + ConnectionsImpl.delete_connection(state, %{ + solar_system_source_id: system.solar_system_id, + solar_system_target_id: sig.linked_system_id + }) + end + + # clear any linked_sig_eve_id on the target system + if sig.linked_system_id do + SystemsImpl.update_system_linked_sig_eve_id(state, %{ + solar_system_id: sig.linked_system_id, + linked_sig_eve_id: nil + }) + end + + # mark as deleted + MapSystemSignature.update!(sig, %{deleted: true}) + end + + defp apply_update_signature(%MapSystemSignature{} = existing, update_params) + when not is_nil(update_params) do + MapSystemSignature.update(existing, update_params) + end + + defp track_activity(event, map_id, solar_system_id, user_id, character_id, signatures) do + ActivityTracker.track_map_event(event, %{ + map_id: map_id, + solar_system_id: solar_system_id, + user_id: user_id, + character_id: character_id, + signatures: signatures + }) + end + + @doc false + defp parse_signatures(signatures, character_eve_id, system_id) do + Enum.map(signatures, fn sig -> + %{ + system_id: system_id, + eve_id: sig["eve_id"], + name: sig["name"], + description: Map.get(sig, "description"), + kind: sig["kind"], + group: sig["group"], + type: Map.get(sig, "type"), + custom_info: Map.get(sig, "custom_info"), + character_eve_id: character_eve_id, + deleted: false + } + end) + end end diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex index 9f55d9b2..6279812b 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex @@ -269,6 +269,39 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do end end + def handle_ui_event( + "undo_delete_signatures", + %{"system_id" => solar_system_id, "eve_ids" => eve_ids} = payload, + %{ + assigns: %{ + map_id: map_id, + main_character_id: main_character_id, + user_permissions: %{update_system: true} + } + } = socket + ) + when not is_nil(main_character_id) do + case WandererApp.Api.MapSystem.read_by_map_and_solar_system(%{ + map_id: map_id, + solar_system_id: get_integer(solar_system_id) + }) do + {:ok, system} -> + restored = + WandererApp.Api.MapSystemSignature.by_system_id_all!(system.id) + |> Enum.filter(fn s -> s.eve_id in eve_ids end) + |> Enum.map(fn s -> + s |> WandererApp.Api.MapSystemSignature.update!(%{deleted: false}) + end) + Phoenix.PubSub.broadcast!(WandererApp.PubSub, map_id, %{ + event: :signatures_updated, + payload: system.solar_system_id + }) + {:noreply, socket} + _ -> + {:noreply, socket} + end + end + def handle_ui_event(event, body, socket), do: MapCoreEventHandler.handle_ui_event(event, body, socket) diff --git a/lib/wanderer_app_web/live/map/map_event_handler.ex b/lib/wanderer_app_web/live/map/map_event_handler.ex index 5d32dea3..fecbdcc2 100644 --- a/lib/wanderer_app_web/live/map/map_event_handler.ex +++ b/lib/wanderer_app_web/live/map/map_event_handler.ex @@ -116,7 +116,8 @@ defmodule WandererAppWeb.MapEventHandler do "update_signatures", "get_signatures", "link_signature_to_system", - "unlink_signature" + "unlink_signature", + "undo_delete_signatures" ] @map_structures_events [ diff --git a/priv/posts/2025/05-08-signature-deletion-flow.md b/priv/posts/2025/05-08-signature-deletion-flow.md new file mode 100644 index 00000000..7e7e9569 --- /dev/null +++ b/priv/posts/2025/05-08-signature-deletion-flow.md @@ -0,0 +1,70 @@ +%{ +title: "Instant Signature Deletion & Undo: A New Flow for Map Signatures", +author: "Wanderer Team", +cover_image_uri: "/images/news/05-08-undo/undo.png", +tags: ~w(signatures deletion undo map realtime guide), +description: "Learn about the new instant signature deletion flow, real-time updates, and the ability to undo removals in Wanderer maps." +} + +--- + +### Introduction + +Managing cosmic signatures is a core part of mapping and navigation in EVE Online. With our latest update, signature deletion is now **instant, real-time, and reversible**—making it easier than ever to keep your map up to date and error-free. + +This guide covers the new signature deletion flow, how to use the undo feature, and what happens behind the scenes to keep your map clean and synchronized for all users. + +--- + +### 1. The New User Flow: Instant, Real-Time, Reversible + +- **Delete a signature:** When you remove a signature, it disappears from your map (and all other users' maps) instantly after a server roundtrip. +- **Undo:** If you make a mistake, you have a window of up to 30s to undo the deletion + +--- + +### 2. How to Use the New Signature Deletion Flow + +1. **Select and Delete:** + - Open the system signatures widget. + - Select one or more signatures and click delete (or paste and use lazy delete). + - The signatures will disappear for all users viewing the same system. + +2. **Undo a Deletion:** + - After deleting, an **Undo** button appears for you (the user who deleted the signature) and remains visible based on your timeout settings. + - Click **Undo** to restore the removed signatures instantly for all users. + - If you don't click Undo in time, the deletion becomes permanent + +3. **Real-Time Updates:** + - All users see signature changes (add, update, remove, undo) in real time—no need to refresh. + + +--- + +### 4. FAQ & Troubleshooting + + +**Q: Who sees the Undo button?** +- Only the user who deleted the signature sees the Undo button + +**Q: Do all users see the same signature list in real time?** +- Yes! All changes are broadcast instantly to everyone viewing the same map. + +**Q: Can I configure the undo timeout?** +- Yes, in the user inteface settings for the signatures widget + +**Q: What about performance?** +- The new flow is optimized for real-time collaboration and efficient cleanup, ensuring your map stays fast and accurate. + +--- + +### 5. Summary + +The new signature deletion flow brings instant, real-time updates and a safety net for accidental removals. Enjoy a more collaborative, error-resistant mapping experience—now live for all Wanderer users! + +--- + +Fly safe, +**The Wanderer Team** + +--- \ No newline at end of file diff --git a/priv/repo/migrations/20250507020200_add_deleted_signature.exs b/priv/repo/migrations/20250507020200_add_deleted_signature.exs new file mode 100644 index 00000000..70d5724d --- /dev/null +++ b/priv/repo/migrations/20250507020200_add_deleted_signature.exs @@ -0,0 +1,23 @@ +defmodule WandererApp.Repo.Migrations.AddDeletedSignature do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:map_system_signatures_v1) do + remove :updated + add :deleted, :boolean, null: false, default: false + end + end + + def down do + alter table(:map_system_signatures_v1) do + remove :deleted + add :updated, :bigint + end + end +end diff --git a/priv/resource_snapshots/repo/map_system_signatures_v1/20250507020200.json b/priv/resource_snapshots/repo/map_system_signatures_v1/20250507020200.json new file mode 100644 index 00000000..d733fc42 --- /dev/null +++ b/priv/resource_snapshots/repo/map_system_signatures_v1/20250507020200.json @@ -0,0 +1,197 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "eve_id", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "character_eve_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "linked_system_id", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "kind", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "group", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "custom_info", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "deleted", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_system_signatures_v1_system_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "map_system_v1" + }, + "size": null, + "source": "system_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "63D26445C9E67459C4D41CF31D61C3EE2356BE664F0D44AB5BC04C2100B701F3", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_system_signatures_v1_uniq_system_eve_id_index", + "keys": [ + { + "type": "atom", + "value": "system_id" + }, + { + "type": "atom", + "value": "eve_id" + } + ], + "name": "uniq_system_eve_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_system_signatures_v1" +} \ No newline at end of file