diff --git a/assets/static/images/news/2025/05-11-map-active-characters/cover.png b/assets/static/images/news/2025/05-11-map-active-characters/cover.png
new file mode 100644
index 00000000..3f366b1a
Binary files /dev/null and b/assets/static/images/news/2025/05-11-map-active-characters/cover.png differ
diff --git a/assets/static/images/news/2025/05-11-map-active-characters/map.png b/assets/static/images/news/2025/05-11-map-active-characters/map.png
new file mode 100644
index 00000000..2394e773
Binary files /dev/null and b/assets/static/images/news/2025/05-11-map-active-characters/map.png differ
diff --git a/assets/static/images/news/2025/05-11-map-active-characters/page.png b/assets/static/images/news/2025/05-11-map-active-characters/page.png
new file mode 100644
index 00000000..6ea63153
Binary files /dev/null and b/assets/static/images/news/2025/05-11-map-active-characters/page.png differ
diff --git a/lib/wanderer_app/character/tracking_utils.ex b/lib/wanderer_app/character/tracking_utils.ex
index f88ff112..6cc4f8db 100644
--- a/lib/wanderer_app/character/tracking_utils.ex
+++ b/lib/wanderer_app/character/tracking_utils.ex
@@ -161,12 +161,12 @@ defmodule WandererApp.Character.TrackingUtils do
end
# Helper functions for character tracking
- def track(_, _, false, _), do: :ok
+
def track([], _map_id, _is_track_character?, _), do: :ok
- def track([character | characters], map_id, true, caller_pid) do
- with :ok <- track_character(character, map_id, caller_pid) do
- track(characters, map_id, true, caller_pid)
+ def track([character | characters], map_id, is_track_allowed, caller_pid) do
+ with :ok <- track_character(character, map_id, is_track_allowed, caller_pid) do
+ track(characters, map_id, is_track_allowed, caller_pid)
end
end
@@ -176,28 +176,55 @@ defmodule WandererApp.Character.TrackingUtils do
eve_id: eve_id
},
map_id,
+ is_track_allowed,
caller_pid
- ) do
- with false <- is_nil(caller_pid) do
- WandererAppWeb.Presence.track(caller_pid, map_id, character_id, %{})
+ )
+ when not is_nil(caller_pid) do
+ WandererAppWeb.Presence.update(caller_pid, map_id, character_id, %{
+ tracked: is_track_allowed,
+ from: DateTime.utc_now()
+ })
+ |> case do
+ {:ok, _} ->
+ :ok
- cache_key = "#{inspect(caller_pid)}_map_#{map_id}:character_#{character_id}:tracked"
+ {:error, :nopresence} ->
+ WandererAppWeb.Presence.track(caller_pid, map_id, character_id, %{
+ tracked: is_track_allowed,
+ from: DateTime.utc_now()
+ })
- case WandererApp.Cache.lookup!(cache_key, false) do
- true ->
- :ok
-
- _ ->
- :ok = Phoenix.PubSub.subscribe(WandererApp.PubSub, "character:#{eve_id}")
- :ok = WandererApp.Cache.put(cache_key, true)
- end
-
- :ok = WandererApp.Character.TrackerManager.start_tracking(character_id)
- else
- true ->
- Logger.error("caller_pid is required for tracking characters")
- {:error, "caller_pid is required"}
+ error ->
+ Logger.error("Failed to update presence: #{inspect(error)}")
+ {:error, "Failed to update presence"}
end
+
+ cache_key = "#{inspect(caller_pid)}_map_#{map_id}:character_#{character_id}:tracked"
+
+ case WandererApp.Cache.lookup!(cache_key, false) do
+ true ->
+ :ok
+
+ _ ->
+ :ok = Phoenix.PubSub.subscribe(WandererApp.PubSub, "character:#{eve_id}")
+ :ok = WandererApp.Cache.put(cache_key, true)
+ end
+
+ if is_track_allowed do
+ :ok = WandererApp.Character.TrackerManager.start_tracking(character_id)
+ end
+
+ :ok
+ end
+
+ defp track_character(
+ _character,
+ _map_id,
+ _is_track_allowed,
+ _caller_pid
+ ) do
+ Logger.error("caller_pid is required for tracking characters")
+ {:error, "caller_pid is required"}
end
def untrack(characters, map_id, caller_pid) do
@@ -206,7 +233,10 @@ defmodule WandererApp.Character.TrackingUtils do
characters
|> Enum.each(fn character ->
- WandererAppWeb.Presence.untrack(caller_pid, map_id, character.id)
+ WandererAppWeb.Presence.update(caller_pid, map_id, character.id, %{
+ tracked: false,
+ from: DateTime.utc_now()
+ })
end)
WandererApp.Map.Server.untrack_characters(map_id, character_ids)
diff --git a/lib/wanderer_app_web/live/map/components/map_characters.ex b/lib/wanderer_app_web/live/map/components/map_characters.ex
index f462d468..43055567 100644
--- a/lib/wanderer_app_web/live/map/components/map_characters.ex
+++ b/lib/wanderer_app_web/live/map/components/map_characters.ex
@@ -17,24 +17,26 @@ defmodule WandererAppWeb.MapCharacters do
|> handle_info_or_assign(assigns)}
end
- # attr(:groups, :any, required: true)
- # attr(:character_settings, :any, required: true)
-
@impl true
def render(assigns) do
~H"""
-
+
@@ -43,31 +45,43 @@ defmodule WandererAppWeb.MapCharacters do
end
attr(:character, :any, required: true)
- attr(:character_settings, :any, required: true)
defp character_entry(assigns) do
~H"""
-
- Tracked
+
+
+ <.local_time id={@character.id} at={@character.from} />
+
+
+
+
+
+
})
+
+
+ {@character.name}
+
+ [{@character.alliance_ticker}]
+
+ [{@character.corporation_ticker}]
+
+
Online
Offline
-
-
-
})
-
-
- {@character.name}
- [{@character.alliance_ticker}]
- [{@character.corporation_ticker}]
+
+
+ Tracked
+
+
+
+ Not Tracked
+
"""
end
@@ -79,12 +93,6 @@ defmodule WandererAppWeb.MapCharacters do
{:noreply, socket}
end
- defp is_tracked?(character_id, character_settings) do
- Enum.any?(character_settings, fn setting ->
- setting.character_id == character_id && setting.tracked
- end)
- end
-
defp is_online?(character_id) do
{:ok, state} = WandererApp.Character.get_character_state(character_id)
state.is_online
diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex
index 8dc4849d..ad49c239 100644
--- a/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex
+++ b/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex
@@ -226,7 +226,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
def handle_ui_event(
_event,
_body,
- %{assigns: %{has_tracked_characters?: false}} =
+ %{assigns: %{has_tracked_characters?: false, can_track?: true}} =
socket
) do
Process.send_after(self(), %{event: :show_tracking}, 10)
@@ -242,7 +242,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
def handle_ui_event(
event,
body,
- %{assigns: %{main_character_id: main_character_id}} =
+ %{assigns: %{main_character_id: main_character_id, can_track?: true}} =
socket
)
when is_nil(main_character_id) do
@@ -260,7 +260,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
{:ok, map_server_started} = WandererApp.Cache.lookup("map_#{map_id}:started", false)
if map_server_started do
- Process.send_after(self(), %{event: :map_server_started}, 10)
+ Process.send_after(self(), %{event: :map_server_started}, 50)
else
WandererApp.Map.Manager.start_map(map_id)
end
@@ -433,6 +433,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
assigns: %{
current_user: current_user,
map_id: map_id,
+ main_character_id: main_character_id,
tracked_characters: tracked_characters,
has_tracked_characters?: has_tracked_characters?,
user_permissions:
@@ -454,7 +455,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
end
events =
- case not has_tracked_characters? do
+ case track_character && not has_tracked_characters? do
true ->
events ++ [:empty_tracked_characters]
@@ -462,13 +463,28 @@ defmodule WandererAppWeb.MapCoreEventHandler do
events
end
+ character_limit_reached? = present_character_ids |> Enum.count() >= characters_limit
+
events =
- case present_character_ids |> Enum.count() < characters_limit do
- true ->
+ cond do
+ # in case user has not tracked any character track his main character as viewer
+ track_character && not has_tracked_characters? ->
+ main_character = Enum.find(current_user.characters, &(&1.id == main_character_id))
+ events ++ [{:track_characters, [main_character], false}]
+
+ track_character && not character_limit_reached? ->
events ++ [{:track_characters, tracked_characters, track_character}]
- _ ->
+ track_character && character_limit_reached? ->
events ++ [:map_character_limit]
+
+ # in case user has view only permissions track his main character as viewer
+ not track_character ->
+ main_character = Enum.find(current_user.characters, &(&1.id == main_character_id))
+ events ++ [{:track_characters, [main_character], track_character}]
+
+ true ->
+ events
end
initial_data =
diff --git a/lib/wanderer_app_web/live/map/map_characters_live.ex b/lib/wanderer_app_web/live/map/map_characters_live.ex
index deaf9729..69b8acc8 100755
--- a/lib/wanderer_app_web/live/map/map_characters_live.ex
+++ b/lib/wanderer_app_web/live/map/map_characters_live.ex
@@ -5,6 +5,8 @@ defmodule WandererAppWeb.MapCharactersLive do
alias WandererAppWeb.MapCharacters
+ @refresh_interval :timer.seconds(30)
+
def mount(
%{"slug" => map_slug} = _params,
_session,
@@ -44,6 +46,15 @@ defmodule WandererAppWeb.MapCharactersLive do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
+ @impl true
+ def handle_info(
+ :refresh_tracking_data,
+ socket
+ ) do
+ Process.send_after(self(), :refresh_tracking_data, @refresh_interval)
+ {:noreply, socket |> load_characters()}
+ end
+
@impl true
def handle_info(
_event,
@@ -101,17 +112,35 @@ defmodule WandererAppWeb.MapCharactersLive do
end
defp apply_action(socket, :index, _params) do
+ Process.send_after(self(), :refresh_tracking_data, @refresh_interval)
+
socket
|> assign(:active_page, :map_characters)
|> assign(:page_title, "Map - Characters")
|> load_characters()
end
+ defp get_all_characters(map_id) do
+ {:ok, present_characters} =
+ WandererApp.Cache.lookup(
+ "map_#{map_id}:presence_data",
+ []
+ )
+
+ present_characters =
+ present_characters
+ |> Enum.map(fn character ->
+ character |> Map.merge(WandererApp.Character.get_character!(character.character_id))
+ end)
+
+ present_characters
+ end
+
defp load_characters(%{assigns: %{map_id: map_id}} = socket) do
map_characters =
map_id
- |> WandererApp.Map.list_characters()
- |> Enum.map(&map_ui_character/1)
+ |> get_all_characters()
+ |> Enum.map(fn character -> map_ui_character(map_id, character) end)
groups =
map_characters
@@ -132,20 +161,22 @@ defmodule WandererAppWeb.MapCharactersLive do
|> assign(:groups, groups)
end
- defp map_ui_character(character),
- do:
- character
- |> Map.take([
- :id,
- :user_id,
- :eve_id,
- :name,
- :online,
- :corporation_id,
- :corporation_name,
- :corporation_ticker,
- :alliance_id,
- :alliance_name,
- :alliance_ticker
- ])
+ defp map_ui_character(map_id, character) do
+ character
+ |> Map.take([
+ :id,
+ :user_id,
+ :eve_id,
+ :name,
+ :online,
+ :corporation_id,
+ :corporation_name,
+ :corporation_ticker,
+ :alliance_id,
+ :alliance_name,
+ :alliance_ticker,
+ :from,
+ :tracked
+ ])
+ end
end
diff --git a/lib/wanderer_app_web/live/map/map_characters_live.html.heex b/lib/wanderer_app_web/live/map/map_characters_live.html.heex
index 5926477f..390587a2 100644
--- a/lib/wanderer_app_web/live/map/map_characters_live.html.heex
+++ b/lib/wanderer_app_web/live/map/map_characters_live.html.heex
@@ -3,7 +3,7 @@
<.link navigate={~p"/#{@map_slug}"} class="text-neutral-100">
<%= @map_name %>
- - Characters [<%= @characters_count %>]
+ - Active Characters [<%= @characters_count %>]
diff --git a/lib/wanderer_app_web/presence.ex b/lib/wanderer_app_web/presence.ex
index f08327f1..8f5754d1 100644
--- a/lib/wanderer_app_web/presence.ex
+++ b/lib/wanderer_app_web/presence.ex
@@ -10,19 +10,39 @@ defmodule WandererAppWeb.Presence do
end
def handle_metas(map_id, %{joins: _joins, leaves: _leaves}, presences, state) do
- presence_character_ids =
+ presence_data =
presences
- |> Enum.map(fn {character_id, _} -> character_id end)
+ |> Enum.map(fn {character_id, meta} ->
+ from =
+ meta
+ |> Enum.map(& &1.from)
+ |> Enum.sort(&(DateTime.compare(&1, &2) != :gt))
+ |> List.first()
+
+ any_tracked = Enum.any?(meta, fn %{tracked: tracked} -> tracked end)
+
+ %{character_id: character_id, tracked: any_tracked, from: from}
+ end)
+
+ presence_tracked_character_ids =
+ presence_data
+ |> Enum.filter(fn %{tracked: tracked} -> tracked end)
+ |> Enum.map(fn %{character_id: character_id} ->
+ character_id
+ end)
WandererApp.Cache.insert("map_#{map_id}:presence_updated", true)
- WandererApp.Cache.insert("map_#{map_id}:presence_character_ids", presence_character_ids)
+
+ WandererApp.Cache.insert(
+ "map_#{map_id}:presence_character_ids",
+ presence_tracked_character_ids
+ )
+
+ WandererApp.Cache.insert(
+ "map_#{map_id}:presence_data",
+ presence_data
+ )
{:ok, state}
end
-
- def presence_character_ids(map_id) do
- map_id
- |> list()
- |> Enum.map(fn {character_id, _} -> character_id end)
- end
end
diff --git a/priv/posts/2025/05-11-map-active-characters.md b/priv/posts/2025/05-11-map-active-characters.md
new file mode 100644
index 00000000..ab2e958d
--- /dev/null
+++ b/priv/posts/2025/05-11-map-active-characters.md
@@ -0,0 +1,77 @@
+%{
+title: "Map Active Characters Page — Interface Guide",
+author: "Wanderer Team",
+cover_image_uri: "/images/news/2025/05-11-map-active-characters/cover.png",
+tags: ~w(characters interface guide map security),
+description: "This interface is essential for managing access and tracking behavior on shared maps, especially in large organizations."
+}
+
+---
+
+### Introduction
+
+
+
+
+This page displays **only currently active characters** — those who have the map page open in an active browser tab or window.
+
+### Key Use Cases:
+- Identify active pilots on your map
+- Monitor user activity and access level
+- Manage tracked status to stay within subscription limits
+
+---
+
+## 👤 Character Grouping by User
+
+Each user may have multiple EVE Online characters authorized. On this page:
+- Characters are **grouped under their owning user**
+- Admins can easily see which characters belong to the same person
+- Useful for distinguishing between multiboxers or corp mates sharing access
+
+---
+
+## 📋 Character Info Displayed
+
+
+
+Each tracked character on this page includes:
+
+| Field | Description |
+|--------------------|-----------------------------------------------------------------------------|
+| **Active From** | Timestamp indicating when the character opened the map (based on real-time browser tab activity) |
+| **Character Info** | Character Name, Corporation, and Alliance |
+| **Tracked Status** | Whether the character is being actively tracked on the map |
+| **Online Status** | Online/offline status (in-game) |
+
+---
+
+## 🔧 Admin Actions
+
+Map **owners and administrators** can:
+
+- ✅ **See viewer-only access**: Identify characters who can view but are not being tracked.
+- 🚫 **Force Untrack** characters:
+ - Stop tracking & remove characters from map.
+ - Useful to stay within your character limit or reset tracking manually.
+ - Note: The user should re-enable tracking later if needed manually in map tracking settings.
+
+---
+
+## 🛑 Notes & Recommendations
+
+- A character is **counted toward the Characters Limit** of the map's subscription **only when tracked**.
+- Tracking begins **as soon as the character opens the map page** in any tab/browser.
+- Closing all tabs with the map **automatically stops tracking** for that character (after a small period about 15 minutes).
+- This system ensures you always have a live (tracking data automatically updated every 30 seconds), accurate picture of map usage across your team.
+
+---
+
+By using the **Map Active Characters** page, admins can efficiently manage map activity, maintain security, and optimize performance across their team or alliance.
+
+---
+
+Fly safe,
+**The Wanderer Team**
+
+---