feat: improve signature undo process

This commit is contained in:
Guarzo
2025-05-07 13:55:19 -04:00
parent 6378754c57
commit da2605ee03
18 changed files with 741 additions and 322 deletions

View File

@@ -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 (

View File

@@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className="w-full">
<div className="flex justify-between items-center text-xs w-full h-full">
@@ -78,7 +71,9 @@ export const SystemSignaturesHeader = ({
<WdImgButton
className={PrimeIcons.UNDO}
style={{ color: 'red' }}
tooltip={{ content: `Undo pending changes (${pendingCount})${formatTimeRemaining()}` }}
tooltip={{
content: `Undo pending deletions (${pendingCount})${undoCountdown && undoCountdown > 0 ? `${undoCountdown}s left` : ''}`,
}}
onClick={onUndoClick}
/>
)}

View File

@@ -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<number>(0);
const [pendingSigs, setPendingSigs] = useState<SystemSignature[]>([]);
const [pendingTimeRemaining, setPendingTimeRemaining] = useState<number | undefined>();
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<string[]>([]);
const [countdown, setCountdown] = useState(0);
const intervalRef = useRef<number | null>(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<Record<string, ExtendedSystemSignature>>, 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<SignatureSettingsType>(
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 (
<Widget
@@ -101,16 +154,16 @@ export const SystemSignatures = () => {
<SystemSignaturesHeader
sigCount={sigCount}
lazyDeleteValue={currentSettings[SETTINGS_KEYS.LAZY_DELETE_SIGNATURES] as boolean}
pendingCount={pendingSigs.length}
pendingTimeRemaining={pendingTimeRemaining}
onLazyDeleteChange={handleLazyDeleteChange}
onUndoClick={handleUndoClick}
onSettingsClick={handleSettingsButtonClick}
pendingCount={pendingIds.length}
undoCountdown={countdown}
onLazyDeleteChange={handleLazyDeleteToggle}
onUndoClick={handleUndo}
onSettingsClick={openSettings}
/>
}
windowId={SIGNATURE_WINDOW_ID}
>
{isNotSelectedSystem ? (
{!isSystemSelected ? (
<div className="w-full h-full flex justify-center items-center select-none text-center text-stone-400/80 text-sm">
System is not selected
</div>
@@ -118,22 +171,17 @@ export const SystemSignatures = () => {
<SystemSignaturesContent
systemId={systemId}
settings={currentSettings}
deletionTiming={
SIGNATURE_DELETION_TIMEOUTS[
(currentSettings[SETTINGS_KEYS.DELETION_TIMING] as keyof typeof SIGNATURE_DELETION_TIMEOUTS) ||
SIGNATURES_DELETION_TIMING.DEFAULT
] as number
}
onLazyDeleteChange={handleLazyDeleteChange}
onCountChange={handleSigCountChange}
onPendingChange={handlePendingChange}
onLazyDeleteChange={handleLazyDeleteToggle}
onCountChange={handleCountChange}
onSignatureDeleted={addDeleted}
/>
)}
{visible && (
<SystemSignatureSettingsDialog
settings={currentSettings}
onCancel={() => setVisible(false)}
onSave={handleSettingsChange}
onSave={handleSettingsSave}
/>
)}
</Widget>

View File

@@ -57,12 +57,8 @@ interface SystemSignaturesContentProps {
onSelect?: (signature: SystemSignature) => void;
onLazyDeleteChange?: (value: boolean) => void;
onCountChange?: (count: number) => void;
onPendingChange?: (
pending: React.MutableRefObject<Record<string, ExtendedSystemSignature>>,
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<SystemSignature | null>(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(

View File

@@ -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<UsePendingDeletionParams, 'deletionTiming'>) {
const { outCommand } = useMapRootState();
const pendingDeletionMapRef = useRef<Record<string, ExtendedSystemSignature>>({});
// 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,
);
},
[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);

View File

@@ -18,8 +18,8 @@ export const useSystemSignaturesData = ({
onCountChange,
onPendingChange,
onLazyDeleteChange,
deletionTiming,
}: UseSystemSignaturesDataProps) => {
onSignatureDeleted,
}: Omit<UseSystemSignaturesDataProps, 'deletionTiming'> & { onSignatureDeleted?: (deletedIds: string[]) => void }) => {
const { outCommand } = useMapRootState();
const [signatures, setSignatures, signaturesRef] = useRefState<ExtendedSystemSignature[]>([]);
const [selectedSignatures, setSelectedSignatures] = useState<ExtendedSystemSignature[]>([]);
@@ -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,

View File

@@ -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 = <T = any>(event: { type: OutCommand; data: any }) => Promise<T>;
export type OutCommandHandler = <T = unknown>(event: { type: OutCommand; data: unknown }) => Promise<T>;

View File

@@ -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 {

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)}")

View File

@@ -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
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
|> ConnectionsImpl.delete_connection(%{
end
end
def update_signatures(state, _), do: state
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: s.linked_system_id
solar_system_target_id: sig.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,
# 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
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())
)
# mark as deleted
MapSystemSignature.update!(sig, %{deleted: true})
end
end)
added_signatures
|> Enum.each(fn s ->
s |> WandererApp.Api.MapSystemSignature.create!()
end)
defp apply_update_signature(%MapSystemSignature{} = existing, update_params)
when not is_nil(update_params) do
MapSystemSignature.update(existing, update_params)
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,
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: system.solar_system_id,
signatures: added_signatures_eve_ids
solar_system_id: solar_system_id,
user_id: user_id,
character_id: character_id,
signatures: signatures
})
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
_ ->
state
end
end
def update_signatures(
state,
_signatures_update
),
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 ->
@doc false
defp parse_signatures(signatures, character_eve_id, system_id) do
Enum.map(signatures, fn sig ->
%{
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
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

View File

@@ -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)

View File

@@ -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 [

View File

@@ -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**
---

View File

@@ -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

View File

@@ -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"
}