fix: fixed activity aggregation and new user tracking (#230)

This commit is contained in:
guarzo
2025-03-11 09:20:10 -06:00
committed by GitHub
parent 2bb45b312c
commit 1590c848c9
18 changed files with 820 additions and 822 deletions

View File

@@ -1,208 +1,82 @@
/* Character Activity Component Styles */
.characterActivity {
&Dialog {
background-color: var(--surface-card);
color: var(--text-color);
width: 80vw;
max-width: 650px;
}
/* Set a fixed height for the container so scrolling works properly */
&Container {
width: 100%;
height: 400px; // fixed height to match the DataTable's scrollHeight
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
margin: 0;
}
}
:global {
.p-dialog-content {
padding: 0;
background-color: var(--surface-card);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
max-height: 60vh;
overflow: hidden;
.p-datatable .p-datatable-thead > tr > th {
background-color: var(--surface-ground);
padding: 0.5rem;
text-align: center;
white-space: normal;
overflow: visible;
height: auto;
}
.p-datatable .p-datatable-tbody > tr > td {
padding: 0.25rem 0.5rem;
}
.p-datatable {
width: 100%;
border: none;
}
.p-datatable-wrapper {
border-radius: 0;
overflow: auto !important;
padding: 0;
margin: 0;
height: 100%;
max-height: 100%;
border: none;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: var(--surface-section);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--surface-border);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--text-color-secondary);
}
}
.p-datatable-table {
width: 100% !important;
table-layout: fixed !important;
border-collapse: collapse;
margin: 0;
padding: 0;
}
/* Empty message styling */
.p-datatable-emptymessage td {
text-align: center;
padding: 2rem;
color: var(--text-color-secondary);
font-style: italic;
border: none !important;
}
}
/* Character info styles */
.characterInfo {
.spinnerContainer {
width: 50px;
height: 50px;
}
.columnHeader {
text-align: center;
font-weight: 600;
font-size: 0.75rem; /* text-xs */
white-space: normal !important;
overflow: visible !important;
}
.numericColumnHeader {
padding: 0.5rem !important;
}
.dataTable {
width: 100%;
border: none;
}
.cellContent {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
overflow: hidden;
padding: 0.25rem 0;
}
.characterPortrait {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.characterNameContainer {
.characterInfo {
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
min-width: 0;
width: 100%;
}
.characterName {
display: flex;
font-weight: 500;
width: 100%;
overflow: hidden;
font-size: 12px;
line-height: 1.333;
white-space: nowrap;
text-overflow: ellipsis;
gap: 0.5rem;
}
.nameText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
display: inline;
max-width: 180px;
width: 100%;
}
.corporationTicker {
color: var(--text-color-secondary);
opacity: 0.8;
display: inline;
font-size: 0.75rem;
.characterTicker {
white-space: nowrap;
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
}
.allianceTicker {
color: var(--text-color-secondary);
font-size: 0.75rem;
}
/* Cell styling */
.characterNameCell {
display: flex;
align-items: center;
padding: 2px;
width: 100%;
overflow: hidden;
}
.activityValueCell {
.numericValueCell {
text-align: center;
font-size: 0.75rem; /* text-xs */
font-weight: 500;
font-size: 12px;
line-height: 1.333;
font-variant-numeric: tabular-nums;
}
/* Column styles */
.characterColumn {
min-width: 200px;
width: 40%;
}
.numericColumn {
width: 20%;
text-align: center;
white-space: nowrap;
}
/* Empty state message */
.emptyMessage {
padding: 2rem;
text-align: center;
color: var(--text-color-secondary);
font-style: italic;
}
/* Loading container */
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
width: 100%;
}
.loadingText {
margin-top: 1rem;
color: var(--text-color-secondary);
font-size: 0.875rem;
}
/* Error message styling */
.errorMessage {
padding: 2rem;
text-align: center;
color: var(--red-500);
}
/* Table row compact style */
.tableRowCompact {
font-size: 12px !important;
line-height: 1.333 !important;
}

View File

@@ -2,13 +2,10 @@ import { useState, useEffect, useMemo } from 'react';
import { Dialog } from 'primereact/dialog';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import classes from './CharacterActivity.module.scss';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { ProgressSpinner } from 'primereact/progressspinner';
import classes from './CharacterActivity.module.scss';
/**
* Summary of a character's activity
*/
export interface ActivitySummary {
character_id: string;
character_name: string;
@@ -28,29 +25,26 @@ interface CharacterActivityProps {
onHide: () => void;
}
const getRowClassName = () => [classes.tableRowCompact, 'p-selectable-row'];
const getRowClassName = () => ['text-xs leading-tight', 'p-selectable-row'];
const renderCharacterTemplate = (rowData: ActivitySummary) => {
const displayName = rowData.is_user ? rowData.user_name : rowData.character_name;
const ticker = rowData.corporation_ticker;
const allianceTicker = rowData.alliance_ticker ? `[${rowData.alliance_ticker}]` : '';
return (
<div className={classes.characterNameCell}>
<div className={classes.cellContent}>
<div className="w-6 h-6 rounded-full overflow-hidden flex-shrink-0 mr-3">
<img src={rowData.portrait_url} alt={displayName} className="w-full h-full object-cover" />
</div>
<div className={classes.characterInfo}>
<div className={classes.characterPortrait}>
<img src={rowData.portrait_url} alt={rowData.character_name} className="w-full h-full object-cover" />
</div>
<div className={classes.characterNameContainer}>
<div className={classes.characterName}>
{rowData.is_user ? (
<>
<span className={classes.nameText}>{rowData.user_name}</span>
<span className={classes.corporationTicker}>[{rowData.corporation_ticker}]</span>
</>
) : (
<>
<span className={classes.nameText}>{rowData.character_name}</span>
<span className={classes.corporationTicker}>[{rowData.corporation_ticker}]</span>
</>
)}
<span className="font-medium text-text-color">{displayName}</span>
</div>
<div className={classes.characterTicker}>
<span className="text-text-color-secondary text-xs">
[{ticker}] {allianceTicker}
</span>
</div>
</div>
</div>
@@ -58,19 +52,15 @@ const renderCharacterTemplate = (rowData: ActivitySummary) => {
};
const renderValueTemplate = (rowData: ActivitySummary, field: keyof ActivitySummary) => {
return <div className={classes.activityValueCell}>{rowData[field] as number}</div>;
return <div className={`${classes.numericValueCell} tabular-nums`}>{rowData[field] as number}</div>;
};
/**
* Component that displays character activity in a dialog.
*/
export const CharacterActivity = ({ visible, onHide }: CharacterActivityProps) => {
const { data } = useMapRootState();
const { characterActivityData } = data;
const [localActivity, setLocalActivity] = useState<ActivitySummary[]>([]);
const [loading, setLoading] = useState(true);
// Use the new structure directly
const activity = useMemo(() => {
return characterActivityData?.activity || [];
}, [characterActivityData]);
@@ -80,34 +70,31 @@ export const CharacterActivity = ({ visible, onHide }: CharacterActivityProps) =
setLoading(characterActivityData?.loading !== false);
}, [activity, characterActivityData]);
const renderContent = () => {
if (loading) {
return (
<Dialog
header="Character Activity"
visible={visible}
className={classes.characterActivityDialog}
onHide={onHide}
dismissableMask
draggable={false}
resizable={false}
closable
modal={true}
>
<div className={classes.characterActivityContainer}>
{loading && (
<div className={classes.loadingContainer}>
<ProgressSpinner style={{ width: '50px', height: '50px' }} strokeWidth="4" />
<div className={classes.loadingText}>Loading character activity data...</div>
<div className="flex flex-col items-center justify-center h-[400px] w-full">
<ProgressSpinner className={classes.spinnerContainer} strokeWidth="4" />
<div className="mt-4 text-text-color-secondary text-sm">Loading character activity data...</div>
</div>
)}
{!loading && localActivity.length === 0 && (
<div className={classes.emptyMessage}>No character activity data available</div>
)}
{!loading && localActivity.length > 0 && (
);
}
if (localActivity.length === 0) {
return (
<div className="p-8 text-center text-text-color-secondary italic">No character activity data available</div>
);
}
return (
<DataTable
value={localActivity}
className={classes.characterActivityDatatable}
scrollable
scrollHeight="400px"
resizableColumns
columnResizeMode="fit"
className="w-full"
tableClassName={classes.dataTable}
emptyMessage="No character activity data available"
sortField="passages"
sortOrder={-1}
@@ -121,36 +108,55 @@ export const CharacterActivity = ({ visible, onHide }: CharacterActivityProps) =
header="Character"
body={renderCharacterTemplate}
sortable
headerStyle={{ minWidth: '75px', padding: '0.5rem', height: 'auto', overflow: 'visible' }}
bodyStyle={{ minWidth: '75px' }}
className={classes.characterColumn}
headerClassName={`${classes.headerCharacter} text-xs`}
headerClassName={`${classes.columnHeader} ${classes.characterHeader}`}
/>
<Column
field="passages"
header="Passages"
body={rowData => renderValueTemplate(rowData, 'passages')}
sortable
headerStyle={{ width: '120px', textAlign: 'center', padding: '0.5rem', height: 'auto', overflow: 'visible' }}
bodyStyle={{ width: '120px', textAlign: 'center' }}
className={classes.numericColumn}
headerClassName={`${classes.headerStandard} text-xs`}
headerClassName={`${classes.columnHeader} ${classes.numericColumnHeader}`}
/>
<Column
field="connections"
header="Connections"
body={rowData => renderValueTemplate(rowData, 'connections')}
sortable
headerStyle={{ width: '120px', textAlign: 'center', padding: '0.5rem', height: 'auto', overflow: 'visible' }}
bodyStyle={{ width: '120px', textAlign: 'center' }}
className={classes.numericColumn}
headerClassName={`${classes.headerStandard} text-xs`}
headerClassName={`${classes.columnHeader} ${classes.numericColumnHeader}`}
/>
<Column
field="signatures"
header="Signatures"
body={rowData => renderValueTemplate(rowData, 'signatures')}
sortable
headerStyle={{ width: '120px', textAlign: 'center', padding: '0.5rem', height: 'auto', overflow: 'visible' }}
bodyStyle={{ width: '120px', textAlign: 'center' }}
className={classes.numericColumn}
headerClassName={`${classes.headerStandard} text-xs`}
headerClassName={`${classes.columnHeader} ${classes.numericColumnHeader}`}
/>
</DataTable>
)}
</div>
);
};
return (
<Dialog
header="Character Activity"
visible={visible}
className="bg-surface-card text-text-color w-11/12 max-w-[600px]"
onHide={onHide}
dismissableMask
>
<div className="w-full h-[400px] flex flex-col overflow-hidden p-0 m-0">{renderContent()}</div>
</Dialog>
);
};

View File

@@ -1,40 +1,3 @@
/* TrackAndFollow Dialog Styles */
.trackFollowDialog {
width: 500px;
:global {
/* Dialog content */
.p-dialog-content {
padding: 0;
max-height: 70vh;
overflow: hidden;
}
}
}
/* Character grid styles */
.characterGrid {
display: grid;
grid-template-columns: 1fr;
width: 100%;
border: 1px solid var(--surface-border);
overflow: hidden;
}
.characterGridHeader {
display: grid;
grid-template-columns: 80px 80px 1fr;
background-color: var(--surface-section);
border-bottom: 1px solid var(--surface-border);
font-weight: 600;
font-size: 0.875rem;
color: var(--text-color);
padding: 2px;
text-align: center;
}
.characterGridBody {
display: grid;
grid-template-columns: 1fr;
width: 100%;
.trackFollowHeader {
background-color: var(--surface-ground);
}

View File

@@ -1,11 +1,11 @@
import { useState, useEffect, useMemo } from 'react';
import { Dialog } from 'primereact/dialog';
import { VirtualScroller } from 'primereact/virtualscroller';
import classes from './TrackAndFollow.module.scss';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers';
import { TrackingCharacterWrapper } from './TrackingCharacterWrapper';
import { TrackingCharacter } from './types';
import classes from './TrackAndFollow.module.scss';
interface TrackAndFollowProps {
visible: boolean;
@@ -28,30 +28,30 @@ export const TrackAndFollow = ({ visible, onHide }: TrackAndFollowProps) => {
const characters = useMemo(() => trackingCharactersData || [], [trackingCharactersData]);
useEffect(() => {
if (!visible || characters.length === 0) {
return;
}
const tracked = characters.filter(char => char.tracked).map(char => char.id);
setTrackedCharacters(tracked);
if (trackingCharactersData) {
const newTrackedCharacters = trackingCharactersData
.filter(character => character.tracked)
.map(character => character.id);
const followed = characters.find(char => char.followed);
setFollowedCharacter(followed ? followed.id : null);
}, [visible, characters]);
setTrackedCharacters(newTrackedCharacters);
const followedChar = trackingCharactersData.find(character => character.followed);
if (followedChar?.id !== followedCharacter) {
setFollowedCharacter(followedChar?.id || null);
}
}
}, [followedCharacter, trackingCharactersData]);
const handleTrackToggle = (characterId: string) => {
setTrackedCharacters(prev => {
if (!prev.includes(characterId)) {
return [...prev, characterId];
const isCurrentlyTracked = trackedCharacters.includes(characterId);
if (isCurrentlyTracked) {
setTrackedCharacters(prev => prev.filter(id => id !== characterId));
} else {
setTrackedCharacters(prev => [...prev, characterId]);
}
if (followedCharacter === characterId) {
setFollowedCharacter(null);
outCommand({
type: OutCommand.toggleFollow,
data: { 'character-id': characterId },
});
}
return prev.filter(id => id !== characterId);
});
outCommand({
type: OutCommand.toggleTrack,
data: { 'character-id': characterId },
@@ -59,14 +59,31 @@ export const TrackAndFollow = ({ visible, onHide }: TrackAndFollowProps) => {
};
const handleFollowToggle = (characterId: string) => {
if (followedCharacter !== characterId && !trackedCharacters.includes(characterId)) {
const isCurrentlyFollowed = followedCharacter === characterId;
const isCurrentlyTracked = trackedCharacters.includes(characterId);
// If not followed and not tracked, we need to track it first
if (!isCurrentlyFollowed && !isCurrentlyTracked) {
setTrackedCharacters(prev => [...prev, characterId]);
// Send track command first
outCommand({
type: OutCommand.toggleTrack,
data: { 'character-id': characterId },
});
// Then send follow command after a short delay
setTimeout(() => {
outCommand({
type: OutCommand.toggleFollow,
data: { 'character-id': characterId },
});
}, 100);
return;
}
setFollowedCharacter(prev => (prev === characterId ? null : characterId));
// Otherwise just toggle follow
outCommand({
type: OutCommand.toggleFollow,
data: { 'character-id': characterId },
@@ -91,24 +108,23 @@ export const TrackAndFollow = ({ visible, onHide }: TrackAndFollowProps) => {
header={renderHeader()}
visible={visible}
onHide={onHide}
modal
className={classes.trackFollowDialog}
closeOnEscape
showHeader={true}
closable={true}
className="w-[500px] bg-surface-card text-text-color"
>
<div className="w-full overflow-hidden">
<div
className={`
grid grid-cols-[80px_80px_1fr]
${classes.trackFollowHeader}
border-b border-surface-border
font-normal text-sm text-text-color
p-0.5 text-center
`}
>
<div className={classes.characterGrid}>
<div className={classes.characterGridHeader}>
<div>Track</div>
<div>Follow</div>
<div>Character</div>
<div className="text-center">Character</div>
</div>
<VirtualScroller
items={characters}
itemSize={48}
itemTemplate={rowTemplate}
className={`${classes.characterGridBody} h-72 w-full`}
/>
<VirtualScroller items={characters} itemSize={48} itemTemplate={rowTemplate} className="h-72 w-full" />
</div>
</Dialog>
);

View File

@@ -1,118 +1,10 @@
/* Character grid row styles */
.characterGridRow {
display: grid;
grid-template-columns: 80px 80px 1fr;
border-bottom: 1px solid var(--surface-border);
padding: 2px;
align-items: center;
transition: background-color 0.2s;
min-height: 32px;
&:hover {
background-color: var(--surface-hover);
}
.characterRow {
border-color: var(--surface-border);
border-width: 0 0 1px 0;
border-style: solid;
opacity: 0.5;
&:last-child {
border-bottom: none;
}
}
.gridCellTrack,
.gridCellFollow {
display: flex;
justify-content: center;
align-items: center;
padding: 2px;
text-align: center;
}
.gridCellCharacter {
padding: 2px;
display: flex;
align-items: center;
}
/* Character info styles */
.characterInfo {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
overflow: hidden;
min-height: 32px;
}
.characterPortrait {
width: 2rem;
height: 2rem;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.characterDetails {
display: flex;
align-items: center;
overflow: hidden;
flex-wrap: nowrap;
white-space: nowrap;
}
.characterName {
font-weight: 500;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
}
.checkboxContainer {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.radioContainer {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.portraitImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.corporationTicker {
margin-left: 0.5rem;
color: var(--text-color-secondary, #9ca3af);
font-size: 0.875rem;
}
.allianceTicker {
margin-left: 0.25rem;
color: var(--text-color-secondary, #9ca3af);
font-size: 0.875rem;
}
/* Responsive styles */
@media (max-width: 640px) {
.characterGridRow {
grid-template-columns: 60px 60px 1fr;
}
.characterPortrait {
width: 2rem;
height: 2rem;
}
}

View File

@@ -1,8 +1,8 @@
import { TrackingCharacter } from './types';
import { WdCheckbox } from '@/hooks/Mapper/components/ui-kit/WdCheckbox/WdCheckbox';
import WdRadioButton from '@/hooks/Mapper/components/ui-kit/WdRadioButton';
import classes from './TrackingCharacterWrapper.module.scss';
import { TooltipPosition, WdTooltipWrapper } from '../../../ui-kit';
import classes from './TrackingCharacterWrapper.module.scss';
interface TrackingCharacterWrapperProps {
character: TrackingCharacter;
@@ -23,35 +23,46 @@ export const TrackingCharacterWrapper = ({
const followRadioId = `follow-${character.id}`;
return (
<div className={classes.characterGridRow}>
<div className={classes.gridCellTrack}>
<div
className={`
grid grid-cols-[80px_80px_1fr]
${classes.characterRow}
p-0.5 items-center transition-colors duration-200 min-h-8 hover:bg-surface-hover
`}
>
<div className="flex justify-center items-center p-0.5 text-center">
<WdTooltipWrapper content="Track this character on the map" position={TooltipPosition.top}>
<div className={classes.checkboxContainer}>
<div className="flex justify-center items-center w-full">
<WdCheckbox id={trackCheckboxId} label="" value={isTracked} onChange={() => onTrackToggle()} />
</div>
</WdTooltipWrapper>
</div>
<div className={classes.gridCellFollow}>
<div className="flex justify-center items-center p-0.5 text-center">
<WdTooltipWrapper content="Follow this character's movements on the map" position={TooltipPosition.top}>
<div className={classes.radioContainer}>
<WdRadioButton
id={followRadioId}
name="followed_character"
checked={isFollowed}
onChange={() => onFollowToggle()}
/>
<div className="flex justify-center items-center w-full">
<div onClick={onFollowToggle} className="cursor-pointer">
<WdRadioButton id={followRadioId} name="followed_character" checked={isFollowed} onChange={() => {}} />
</div>
</div>
</WdTooltipWrapper>
</div>
<div className={classes.gridCellCharacter}>
<div className={classes.characterInfo}>
<div className={classes.characterPortrait}>
<img src={character.portrait_url} alt={character.name} className={classes.portraitImage} />
<div className="p-0.5 flex items-center justify-center">
<div className="flex items-center gap-3 w-full overflow-hidden min-h-8 justify-center">
<div className="w-8 h-8 rounded-full overflow-hidden flex-shrink-0">
<img src={character.portrait_url} alt={character.name} className="w-full h-full object-cover" />
</div>
<div className={classes.characterDetails}>
<span className={classes.characterName}>{character.name}</span>
<span className={classes.corporationTicker}>[{character.corporation_ticker}]</span>
{character.alliance_ticker && <span className={classes.allianceTicker}>[{character.alliance_ticker}]</span>}
<div className="flex items-center overflow-hidden flex-nowrap whitespace-nowrap">
<span
className={`
text-sm text-color-color whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]
`}
>
{character.name}
</span>
<span className="ml-2 text-text-color-secondary text-sm">[{character.corporation_ticker}]</span>
{character.alliance_ticker && (
<span className="ml-1 text-text-color-secondary text-sm">[{character.alliance_ticker}]</span>
)}
</div>
</div>
</div>

View File

@@ -13,17 +13,17 @@ export const useTrackAndFollowHandlers = () => {
* Handle hiding the track and follow dialog
*/
const handleHideTracking = useCallback(() => {
// Update local state to hide the dialog
update(state => ({
...state,
showTrackAndFollow: false,
}));
// Send the command to the server
// Send the command to the server first
outCommand({
type: OutCommand.hideTracking,
data: {},
});
// Then update local state to hide the dialog
update(state => ({
...state,
showTrackAndFollow: false,
}));
}, [outCommand, update]);
/**

View File

@@ -146,6 +146,30 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
userSettingsUpdated(data as CommandUserSettingsUpdated);
break;
case Commands.showTracking:
// This command is handled by the TrackAndFollow component
break;
case Commands.hideTracking:
// This command is handled by the TrackAndFollow component
break;
case Commands.showActivity:
// This command is handled by the CharacterActivity component
break;
case Commands.hideActivity:
// This command is handled by the CharacterActivity component
break;
case Commands.toggleTrack:
// This command is handled by the TrackAndFollow component
break;
case Commands.toggleFollow:
// This command is handled by the TrackAndFollow component
break;
default:
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
break;

View File

@@ -63,19 +63,59 @@ defmodule WandererApp.Api.MapCharacterSettings do
end
update :track do
change(set_attribute(:tracked, true))
accept [:map_id, :character_id]
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
# Load the record first
load do
filter expr(map_id == ^arg(:map_id) and character_id == ^arg(:character_id))
end
# Only update the tracked field
change set_attribute(:tracked, true)
end
update :untrack do
change(set_attribute(:tracked, false))
accept [:map_id, :character_id]
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
# Load the record first
load do
filter expr(map_id == ^arg(:map_id) and character_id == ^arg(:character_id))
end
# Only update the tracked field
change set_attribute(:tracked, false)
end
update :follow do
change(set_attribute(:followed, true))
accept [:map_id, :character_id]
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
# Load the record first
load do
filter expr(map_id == ^arg(:map_id) and character_id == ^arg(:character_id))
end
# Only update the followed field
change set_attribute(:followed, true)
end
update :unfollow do
change(set_attribute(:followed, false))
accept [:map_id, :character_id]
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
# Load the record first
load do
filter expr(map_id == ^arg(:map_id) and character_id == ^arg(:character_id))
end
# Only update the followed field
change set_attribute(:followed, false)
end
end

View File

@@ -257,4 +257,51 @@ defmodule WandererApp.Character do
corporation: true
}
end
@doc """
Finds a character by EVE ID from a user's active characters.
## Parameters
- `current_user`: The current user struct
- `character_eve_id`: The EVE ID of the character to find
## Returns
- `{:ok, character}` if the character is found
- `{:error, :character_not_found}` if the character is not found
"""
def find_character_by_eve_id(current_user, character_eve_id) do
{:ok, all_user_characters} =
WandererApp.Api.Character.active_by_user(%{user_id: current_user.id})
case Enum.find(all_user_characters, fn char ->
"#{char.eve_id}" == "#{character_eve_id}"
end) do
nil ->
{:error, :character_not_found}
character ->
{:ok, character}
end
end
@doc """
Finds a character by character ID from a user's characters.
## Parameters
- `current_user`: The current user struct
- `char_id`: The character ID to find
## Returns
- `{:ok, character}` if the character is found
- `{:error, :character_not_found}` if the character is not found
"""
def find_user_character(current_user, char_id) do
case Enum.find(current_user.characters, &("#{&1.id}" == "#{char_id}")) do
nil ->
{:error, :character_not_found}
char ->
{:ok, char}
end
end
end

View File

@@ -1,7 +1,8 @@
defmodule WandererApp.Utils.CharacterUtil do
defmodule WandererApp.Character.Activity do
@moduledoc """
Utility functions for character-related operations.
Functions for processing and managing character activity data.
"""
require Logger
@doc """
Finds a followed character ID from a list of character settings and activities.
@@ -99,7 +100,6 @@ defmodule WandererApp.Utils.CharacterUtil do
|> group_by_user_id()
|> process_users_activity(character_settings, user_characters, current_user)
|> sort_by_timestamp()
|> group_and_select_most_active()
end
defp group_by_user_id(activities) do
@@ -191,7 +191,7 @@ defmodule WandererApp.Utils.CharacterUtil do
end
end
defp get_character_details(char_id, char_activities, user_characters, true) do
defp get_character_details(char_id, _char_activities, user_characters, true) do
Enum.find(user_characters, fn char ->
char.id == char_id || to_string(char.eve_id) == char_id
end)
@@ -207,7 +207,13 @@ defmodule WandererApp.Utils.CharacterUtil do
}
end
defp build_activity_entry(char_details, char_activities, current_user, is_current_user, user_id) do
defp build_activity_entry(
char_details,
char_activities,
current_user,
is_current_user,
_user_id
) do
%{
character_id: char_details.eve_id || char_details.id,
character_name: char_details.name,
@@ -222,9 +228,6 @@ defmodule WandererApp.Utils.CharacterUtil do
}
end
defp get_system_info(activities, key, default),
do: Map.get(List.first(activities) || %{}, key, default)
defp sum_activity(activities, key),
do: activities |> Enum.map(&Map.get(&1, key, 0)) |> Enum.sum()
@@ -238,21 +241,4 @@ defmodule WandererApp.Utils.CharacterUtil do
defp sort_by_timestamp(activities) do
Enum.sort_by(activities, & &1.timestamp, {:desc, DateTime})
end
defp group_and_select_most_active(activities) do
activities
|> Enum.group_by(&Map.get(&1, :user_id, "unknown"))
|> Enum.map(fn {_user_id, user_activities} ->
user_activities
|> Enum.sort_by(
fn activity ->
Map.get(activity, :passages, 0) +
Map.get(activity, :connections, 0) +
Map.get(activity, :signatures, 0)
end,
:desc
)
|> List.first()
end)
end
end

View File

@@ -7,7 +7,6 @@ defmodule WandererApp.Map do
require Logger
alias WandererApp.Utils.EVEUtil
alias WandererApp.Utils.CharacterUtil
defstruct map_id: nil,
name: nil,
@@ -527,13 +526,12 @@ defmodule WandererApp.Map do
defp _maybe_limit_list(list, limit), do: Enum.take(list, limit)
@doc """
Gets raw character activity data for a map.
Returns the raw activity data that can be processed by CharacterUtil.
Returns the raw activity data that can be processed by WandererApp.Character.Activity.
Only includes characters that are on the map's ACL.
"""
def get_character_activity(map_id) do
{:ok, map} = WandererApp.Api.Map.by_id(map_id)
map = Ash.load!(map, :acls)
_map_with_acls = Ash.load!(map, :acls)
{:ok, jumps} = WandererApp.Api.MapChainPassages.by_map_id(%{map_id: map_id})
thirty_days_ago = DateTime.utc_now() |> DateTime.add(-30 * 24 * 3600, :second)

View File

@@ -37,18 +37,63 @@ defmodule WandererApp.MapCharacterSettingsRepo do
end
end
def track(settings), do: settings |> WandererApp.Api.MapCharacterSettings.track()
def untrack(settings), do: settings |> WandererApp.Api.MapCharacterSettings.untrack()
def track(settings) do
# Only update the tracked field, preserving other fields
WandererApp.Api.MapCharacterSettings.track(%{
map_id: settings.map_id,
character_id: settings.character_id
})
end
def track!(settings), do: settings |> WandererApp.Api.MapCharacterSettings.track!()
def untrack!(settings), do: settings |> WandererApp.Api.MapCharacterSettings.untrack!()
def untrack(settings) do
# Only update the tracked field, preserving other fields
WandererApp.Api.MapCharacterSettings.untrack(%{
map_id: settings.map_id,
character_id: settings.character_id
})
end
def follow(settings), do: settings |> WandererApp.Api.MapCharacterSettings.follow()
def unfollow(settings), do: settings |> WandererApp.Api.MapCharacterSettings.unfollow()
def track!(settings),
do:
WandererApp.Api.MapCharacterSettings.track!(%{
map_id: settings.map_id,
character_id: settings.character_id
})
def follow!(settings), do: settings |> WandererApp.Api.MapCharacterSettings.follow!()
def unfollow!(settings), do: settings |> WandererApp.Api.MapCharacterSettings.unfollow!()
def untrack!(settings),
do:
WandererApp.Api.MapCharacterSettings.untrack!(%{
map_id: settings.map_id,
character_id: settings.character_id
})
def follow(settings) do
WandererApp.Api.MapCharacterSettings.follow(%{
map_id: settings.map_id,
character_id: settings.character_id
})
end
def unfollow(settings) do
WandererApp.Api.MapCharacterSettings.unfollow(%{
map_id: settings.map_id,
character_id: settings.character_id
})
end
def follow!(settings),
do:
WandererApp.Api.MapCharacterSettings.follow!(%{
map_id: settings.map_id,
character_id: settings.character_id
})
def unfollow!(settings),
do:
WandererApp.Api.MapCharacterSettings.unfollow!(%{
map_id: settings.map_id,
character_id: settings.character_id
})
def destroy!(settings), do: settings |> WandererApp.Api.MapCharacterSettings.destroy!()
end

View File

@@ -7,7 +7,6 @@ defmodule WandererAppWeb.MapAPIController do
alias WandererApp.Api
alias WandererApp.Api.Character
alias WandererApp.Api.MapSolarSystem
alias WandererApp.MapSystemRepo
alias WandererApp.MapCharacterSettingsRepo

View File

@@ -103,28 +103,66 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
}
} = socket
) do
# Get all character settings to preserve followed state
{:ok, all_settings} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
# Get tracked characters
{:ok, map_characters} = WandererApp.Maps.get_tracked_map_characters(map_id, current_user)
user_character_eve_ids = map_characters |> Enum.map(& &1.eve_id)
# Update socket assigns but don't affect followed state
socket =
socket
|> assign(user_characters: user_character_eve_ids)
|> assign(has_tracked_characters?: has_tracked_characters?(user_character_eve_ids))
|> MapEventHandler.push_map_event(
"init",
# Get the map with ACLs for building tracking data
{:ok, map} = WandererApp.Api.Map.by_id(map_id)
map = Ash.load!(map, :acls)
# Get characters that have access to the map
{:ok, %{characters: characters_with_access}} =
WandererApp.Maps.load_characters(map, all_settings, current_user.id)
{:ok, latest_settings} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
# Create tracking data that preserves followed state
tracking_data =
characters_with_access
|> Enum.map(fn char ->
# Find existing settings to preserve followed state
# Use the latest settings to ensure we have the most up-to-date followed state
setting = Enum.find(latest_settings, &(&1.character_id == char.id))
# Keep the existing tracked and followed states
tracked = if setting, do: setting.tracked, else: false
followed = if setting, do: setting.followed, else: false
%{
user_characters: user_character_eve_ids,
reset: false
id: char.eve_id,
name: char.name,
corporation_ticker: char.corporation_ticker,
alliance_ticker: Map.get(char, :alliance_ticker, ""),
portrait_url: EVEUtil.get_portrait_url(char.eve_id),
tracked: tracked,
followed: followed
}
end)
socket
|> MapEventHandler.push_map_event(
"tracking_characters_data",
%{characters: tracking_data}
)
end
def handle_server_event(event, socket),
do: MapCoreEventHandler.handle_server_event(event, socket)
# UI Event Handlers
def handle_ui_event(
"toggle_track",
%{"character-id" => character_eve_id},
%{"character-id" => clicked_char_id},
%{
assigns: %{
map_id: map_id,
@@ -133,59 +171,87 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
}
} = socket
) do
# Get all user characters
{:ok, all_user_characters} =
WandererApp.Api.Character.active_by_user(%{user_id: current_user.id})
# First, get all existing settings to preserve states
{:ok, all_settings} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
# Find the character that was clicked
character =
Enum.find(all_user_characters, fn char ->
"#{char.eve_id}" == "#{character_eve_id}"
# Save the followed character ID and settings before making any changes
{followed_character_id, _followed_character_settings} =
all_settings
|> Enum.find(& &1.followed)
|> case do
nil -> {nil, nil}
setting -> {setting.character_id, setting}
end
# Find the character we're toggling
with {:ok, character} <-
WandererApp.Character.find_character_by_eve_id(current_user, clicked_char_id),
{:ok, updated_settings} <-
toggle_character_tracking(character, map_id, only_tracked_characters) do
# Get the map with ACLs
{:ok, map} = WandererApp.Api.Map.by_id(map_id)
map = Ash.load!(map, :acls)
# Get characters that have access to the map
{:ok, %{characters: characters_with_access}} =
WandererApp.Maps.load_characters(map, all_settings, current_user.id)
# If there was a followed character before, check if it's still followed
# Only check if we're not toggling the followed character itself
if followed_character_id && followed_character_id != character.id do
# Get the current settings for the followed character
case WandererApp.MapCharacterSettingsRepo.get_by_map(map_id, followed_character_id) do
{:ok, current_settings} ->
# If it's not followed anymore, follow it again
if !current_settings.followed do
{:ok, _} = WandererApp.MapCharacterSettingsRepo.follow(current_settings)
end
_ ->
:ok
end
end
# Get updated settings after potentially restoring followed state
{:ok, new_all_settings} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
# Create tracking data for characters with access to the map
tracking_data =
characters_with_access
|> Enum.map(fn char ->
# For the character being toggled, use the updated settings
setting =
if "#{char.eve_id}" == "#{clicked_char_id}" do
updated_settings
else
# For other characters, use the updated settings
current_setting = Enum.find(new_all_settings, &(&1.character_id == char.id))
# If this was the previously followed character, make sure it's still followed
if followed_character_id && followed_character_id == char.id &&
current_setting && !current_setting.followed do
# This character was previously followed but is no longer followed
# Restore the followed state
%{current_setting | followed: true}
else
current_setting
end
end
tracked = if setting, do: setting.tracked, else: false
followed = if setting, do: setting.followed, else: false
%{
id: char.eve_id,
name: char.name,
corporation_ticker: char.corporation_ticker,
alliance_ticker: Map.get(char, :alliance_ticker, ""),
portrait_url: EVEUtil.get_portrait_url(char.eve_id),
tracked: tracked,
followed: followed
}
end)
if character do
# Get existing settings for this character on this map
case WandererApp.MapCharacterSettingsRepo.get_by_map(map_id, character.id) do
{:ok, existing_settings} ->
# Toggle the tracked status
if existing_settings.tracked do
# Untrack the character
{:ok, updated_settings} =
WandererApp.MapCharacterSettingsRepo.untrack(existing_settings)
# If the character was also followed, unfollow it
if updated_settings.followed do
{:ok, _} = WandererApp.MapCharacterSettingsRepo.unfollow(updated_settings)
end
:ok = untrack_characters([character], map_id)
:ok = remove_characters([character], map_id)
if only_tracked_characters do
Process.send_after(self(), :not_all_characters_tracked, 10)
end
else
# Track the character
{:ok, _} = WandererApp.MapCharacterSettingsRepo.track(existing_settings)
:ok = track_characters([character], map_id, true)
:ok = add_characters([character], map_id, true)
Process.send_after(self(), %{event: :refresh_user_characters}, 10)
end
{:error, :not_found} ->
# Create new settings with tracked=true
{:ok, _} =
WandererApp.MapCharacterSettingsRepo.create(%{
character_id: character.id,
map_id: map_id,
tracked: true,
followed: false
})
end
{:ok, tracking_data} = get_tracking_data(map_id, current_user)
# Send the updated tracking data to the client
{:noreply,
socket
|> MapEventHandler.push_map_event(
@@ -193,6 +259,7 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
%{characters: tracking_data}
)}
else
_ ->
{:noreply, socket}
end
end
@@ -207,12 +274,109 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
{:noreply,
socket
|> MapEventHandler.push_map_event(
"show_tracking",
%{}
)
|> MapEventHandler.push_map_event(
"tracking_characters_data",
%{characters: tracking_data}
)}
end
def handle_ui_event(
"toggle_follow",
%{"character-id" => clicked_char_id},
%{assigns: %{current_user: current_user, map_id: map_id}} = socket
) do
# Get all settings before the operation to see the followed state
{:ok, all_settings_before} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
followed_before = all_settings_before |> Enum.find(& &1.followed)
# Check if the clicked character is already followed
is_already_followed =
followed_before && "#{followed_before.character_id}" == "#{clicked_char_id}"
# Use find_character_by_eve_id from WandererApp.Character
with {:ok, clicked_char} <-
WandererApp.Character.find_character_by_eve_id(current_user, clicked_char_id),
{:ok, _updated_settings} <-
toggle_character_follow(map_id, clicked_char, is_already_followed) do
# Get the state after the toggle_character_follow operation
{:ok, all_settings_after} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
_followed_after = all_settings_after |> Enum.find(& &1.followed)
# Build tracking data
{:ok, tracking_data} = build_tracking_data(map_id, current_user)
# Get the followed character in the tracking data
_followed_in_tracking = tracking_data |> Enum.find(& &1.followed)
{:noreply,
socket
|> MapEventHandler.push_map_event("tracking_characters_data", %{characters: tracking_data})}
else
error ->
Logger.error("Failed to toggle follow: #{inspect(error)}")
{:noreply, socket}
end
end
def handle_ui_event(
"show_activity",
_,
%{assigns: %{map_id: map_id, current_user: current_user}} = socket
) do
socket =
socket
|> MapEventHandler.push_map_event(
"character_activity_data",
%{activity: [], loading: true}
)
task =
Task.async(fn ->
try do
result =
WandererApp.Character.Activity.process_character_activity(map_id, current_user)
{:activity_data, result}
rescue
e ->
Logger.error("Error processing character activity: #{inspect(e)}")
Logger.error("#{Exception.format_stacktrace()}")
{:activity_data, []}
end
end)
{:noreply, socket |> assign(:character_activity_task, task)}
end
def handle_ui_event("hide_activity", _, socket),
do: {:noreply, socket |> assign(show_activity?: false)}
def handle_ui_event("add_character", _, socket) do
{:noreply,
socket
|> MapEventHandler.push_map_event("show_tracking", %{})}
end
def handle_ui_event(
"add_character",
_,
%{assigns: %{user_permissions: %{track_character: false}}} = socket
) do
{:noreply,
socket
|> put_flash(
:error,
"You don't have permissions to track characters. Please contact administrator."
)}
end
def handle_ui_event(event, body, socket),
do: MapCoreEventHandler.handle_ui_event(event, body, socket)
defp get_tracking_data(map_id, current_user) do
# Get character settings for this map
{:ok, character_settings} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
@@ -251,63 +415,6 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
end)}
end
def handle_ui_event(
"toggle_follow",
%{"character-id" => clicked_char_id},
%{assigns: %{map_id: map_id, current_user: current_user}} = socket
) do
with {:ok, clicked_char} <- find_user_character(current_user, clicked_char_id),
{:ok, updated_settings} <- toggle_character_follow(map_id, clicked_char),
{:ok, tracking_data} <- build_tracking_data(map_id, current_user) do
{:noreply,
socket
|> MapEventHandler.push_map_event("tracking_characters_data", %{characters: tracking_data})}
else
_ -> {:noreply, socket}
end
end
def handle_ui_event(
"show_activity",
_,
%{assigns: %{map_id: map_id, current_user: current_user}} = socket
) do
socket =
socket
|> MapEventHandler.push_map_event(
"character_activity_data",
%{activity: [], loading: true}
)
task =
Task.async(fn ->
try do
result =
WandererApp.Utils.CharacterUtil.process_character_activity(map_id, current_user)
{:activity_data, result}
rescue
e ->
Logger.error("Error processing character activity: #{inspect(e)}")
Logger.error("#{Exception.format_stacktrace()}")
{:activity_data, []}
end
end)
{:noreply, socket |> assign(:character_activity_task, task)}
end
def handle_ui_event("hide_activity", _, socket),
do: {:noreply, socket |> assign(show_activity?: false)}
def handle_ui_event(event, params, socket) do
Logger.debug(fn ->
"unhandled event in MapCharactersEventHandler: #{inspect(event)} with params: #{inspect(params)}"
end)
{:noreply, socket}
end
def has_tracked_characters?([]), do: false
def has_tracked_characters?(_user_characters), do: true
@@ -472,13 +579,23 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
{:ok, %{characters: characters_with_access}} =
load_map_characters(map, character_settings, current_user)
socket = init_tracking_state(socket, current_user)
needs_tracking_setup =
needs_tracking_setup?(characters_with_access, character_settings, user_permissions)
socket =
socket
|> assign(:needs_tracking_setup, needs_tracking_setup)
|> then(fn socket ->
if needs_tracking_setup do
socket
else
socket
end
end)
socket
|> init_tracking_state(
characters_with_access,
character_settings,
current_user,
user_permissions
)
end
defp get_map_with_acls(map_id) do
@@ -491,54 +608,26 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
WandererApp.Maps.load_characters(map, character_settings, current_user.id)
end
defp init_tracking_state(
socket,
characters_with_access,
character_settings,
current_user,
user_permissions
) do
def init_tracking_state(socket, current_user) do
user_character_eve_ids = current_user.characters |> Enum.map(& &1.eve_id)
has_tracked_characters? = has_tracked_characters?(user_character_eve_ids)
needs_tracking_setup =
needs_tracking_setup?(
socket.assigns.only_tracked_characters,
characters_with_access,
character_settings,
user_permissions
)
socket
|> assign(
has_tracked_characters?: has_tracked_characters?,
user_characters: user_character_eve_ids,
needs_tracking_setup: needs_tracking_setup
user_characters: user_character_eve_ids
)
end
defp needs_tracking_setup?(
only_tracked_characters,
characters,
character_settings,
user_permissions
) do
tracked_count =
characters
|> Enum.count(fn char ->
setting = Enum.find(character_settings, &(&1.character_id == char.id))
setting && setting.tracked
end)
def needs_tracking_setup?(characters, character_settings, user_permissions) do
untracked_count =
characters
|> Enum.count(fn char ->
setting = Enum.find(character_settings, &(&1.character_id == char.id))
not (setting && setting.tracked)
setting == nil || !setting.tracked
end)
user_permissions.track_character &&
((untracked_count > 0 && only_tracked_characters) || tracked_count == 0)
untracked_count > 0 && user_permissions.track_character
end
@doc """
@@ -597,39 +686,6 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
end)
end
@doc """
Shows the tracking dialog with the given characters.
"""
def show_tracking_dialog(socket, characters_with_access, character_settings) do
tracking_data =
Enum.map(characters_with_access, fn char ->
setting = Enum.find(character_settings, &(&1.character_id == char.id))
tracked = if setting, do: setting.tracked, else: false
followed = if setting, do: setting.followed, else: false
%{
id: char.id,
name: char.name,
portrait_url: EVEUtil.get_portrait_url(char.eve_id, 64),
corporation_ticker: char.corporation_ticker,
alliance_ticker: Map.get(char, :alliance_ticker, ""),
tracked: tracked,
followed: followed
}
end)
socket
|> push_event("map_event", %{
type: "show_tracking",
body: %{}
})
|> push_event("map_event", %{
type: "tracking_characters_data",
body: %{characters: tracking_data}
})
|> assign(:show_tracking, true)
end
def handle_activity_data(socket, activity_data) do
socket
|> MapEventHandler.push_map_event("character_activity_data", %{
@@ -680,54 +736,83 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
end
end
defp find_user_character(current_user, char_id) do
case Enum.find(current_user.characters, &("#{&1.id}" == "#{char_id}")) do
nil -> {:error, :character_not_found}
char -> {:ok, char}
end
end
defp toggle_character_follow(map_id, clicked_char) do
defp toggle_character_follow(map_id, clicked_char, is_already_followed) do
with {:ok, clicked_char_settings} <-
WandererApp.MapCharacterSettingsRepo.get_by_map(map_id, clicked_char.id),
{:ok, settings} <- update_follow_status(map_id, clicked_char, clicked_char_settings) do
WandererApp.MapCharacterSettingsRepo.get_by_map(map_id, clicked_char.id) do
if is_already_followed do
# If already followed, just unfollow without affecting other characters
{:ok, updated_settings} =
WandererApp.MapCharacterSettingsRepo.unfollow(clicked_char_settings)
{:ok, updated_settings}
else
# Normal follow toggle
{:ok, settings} = update_follow_status(map_id, clicked_char, clicked_char_settings)
{:ok, settings}
end
else
{:error, :not_found} ->
# Character not found in settings, create new settings
update_follow_status(map_id, clicked_char, nil)
end
end
defp update_follow_status(map_id, clicked_char, nil) do
# Create new settings with tracked=true and followed=true
# If we're following this character, unfollow all others first
:ok = maybe_unfollow_others(map_id, clicked_char.id, true)
result =
WandererApp.MapCharacterSettingsRepo.create(%{
character_id: clicked_char.id,
map_id: map_id,
tracked: true,
followed: true
})
result
end
defp update_follow_status(map_id, clicked_char, clicked_char_settings) do
# Toggle the followed state
followed = !clicked_char_settings.followed
with :ok <- maybe_unfollow_others(map_id, clicked_char.id, followed),
:ok <- maybe_track_character(clicked_char_settings, followed),
{:ok, settings} <- update_follow(clicked_char_settings, followed) do
{:ok, settings}
# Only unfollow other characters if we're explicitly following this character
# This prevents unfollowing other characters when just tracking a character
if followed do
# We're following this character, so unfollow all others
:ok = maybe_unfollow_others(map_id, clicked_char.id, followed)
end
# If we're following, make sure the character is also tracked
:ok = maybe_track_character(clicked_char_settings, followed)
# Update the follow status
{:ok, settings} = update_follow(clicked_char_settings, followed)
{:ok, settings}
end
defp maybe_unfollow_others(_map_id, _char_id, false), do: :ok
defp maybe_unfollow_others(map_id, char_id, true) do
# This function should only be called when explicitly following a character,
# not when tracking a character. It unfollows all other characters when
# setting a character as followed.
{:ok, all_settings} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
# Unfollow other characters
all_settings
|> Enum.filter(&(&1.character_id != char_id && &1.followed))
|> Enum.each(&WandererApp.MapCharacterSettingsRepo.unfollow/1)
|> Enum.each(fn setting ->
WandererApp.MapCharacterSettingsRepo.unfollow(setting)
end)
:ok
end
defp maybe_track_character(settings, false), do: :ok
defp maybe_track_character(_settings, false), do: :ok
defp maybe_track_character(settings, true) do
if not settings.tracked do
@@ -750,15 +835,15 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
Enum.map(characters_with_access, fn char ->
setting = Enum.find(character_settings, &(&1.character_id == char.id))
tracked = if setting, do: setting.tracked, else: false
# Important: Preserve the followed state
followed = if setting, do: setting.followed, else: false
%{
id: "#{char.id}",
id: char.eve_id,
name: char.name,
eve_id: char.eve_id,
portrait_url: EVEUtil.get_portrait_url(char.eve_id),
corporation_ticker: char.corporation_ticker,
alliance_ticker: Map.get(char, :alliance_ticker, ""),
portrait_url: EVEUtil.get_portrait_url(char.eve_id),
tracked: tracked,
followed: followed
}
@@ -767,4 +852,45 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
{:ok, tracking_data}
end
end
# Helper function to toggle character tracking
defp toggle_character_tracking(character, map_id, _only_tracked_characters) do
case WandererApp.MapCharacterSettingsRepo.get_by_map(map_id, character.id) do
{:ok, existing_settings} ->
if existing_settings.tracked do
# Untrack the character
{:ok, updated_settings} =
WandererApp.MapCharacterSettingsRepo.untrack(existing_settings)
# If the character was followed, we need to unfollow it too
# But we should NOT unfollow other characters
if existing_settings.followed do
{:ok, final_settings} =
WandererApp.MapCharacterSettingsRepo.unfollow(updated_settings)
{:ok, final_settings}
else
{:ok, updated_settings}
end
else
# Track the character
{:ok, updated_settings} =
WandererApp.MapCharacterSettingsRepo.track(existing_settings)
{:ok, updated_settings}
end
{:error, :not_found} ->
# Create new settings
result =
WandererApp.MapCharacterSettingsRepo.create(%{
character_id: character.id,
map_id: map_id,
tracked: true,
followed: false
})
result
end
end
end

View File

@@ -4,7 +4,6 @@ defmodule WandererAppWeb.MapCoreEventHandler do
require Logger
alias WandererAppWeb.{MapEventHandler, MapCharactersEventHandler, MapSystemsEventHandler}
alias WandererApp.Utils.EVEUtil
def handle_server_event(:update_permissions, socket) do
DebounceAndThrottle.Debounce.apply(
@@ -175,7 +174,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
{:noreply, socket}
end
def handle_ui_event("toggle_track_" <> character_id, _, socket),
def handle_ui_event("toggle_track", %{"character-id" => character_id}, socket),
do:
MapCharactersEventHandler.handle_ui_event(
"toggle_track",
@@ -183,7 +182,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
socket
)
def handle_ui_event("toggle_follow_" <> character_id, _, socket),
def handle_ui_event("toggle_follow", %{"character-id" => character_id}, socket),
do:
MapCharactersEventHandler.handle_ui_event(
"toggle_follow",
@@ -239,6 +238,11 @@ defmodule WandererAppWeb.MapCoreEventHandler do
def handle_ui_event("noop", _, socket), do: {:noreply, socket}
def handle_ui_event(event, body, socket) do
Logger.debug(fn -> "unhandled map ui event: #{inspect(event)} #{inspect(body)}" end)
{:noreply, socket}
end
def handle_ui_event(
_event,
_body,
@@ -256,11 +260,6 @@ defmodule WandererAppWeb.MapCoreEventHandler do
)
)
def handle_ui_event(event, body, socket) do
Logger.debug(fn -> "unhandled map ui event: #{inspect(event)} #{inspect(body)}" end)
{:noreply, socket}
end
defp maybe_start_map(map_id) do
{:ok, map_server_started} = WandererApp.Cache.lookup("map_#{map_id}:started", false)
@@ -544,8 +543,8 @@ defmodule WandererAppWeb.MapCoreEventHandler do
# Initialize character tracking
socket =
socket
|> MapCharactersEventHandler.init_character_tracking(
MapCharactersEventHandler.init_character_tracking(
socket,
map_id,
%{
current_user: current_user,
@@ -598,22 +597,4 @@ defmodule WandererAppWeb.MapCoreEventHandler do
user_character_eve_ids |> Enum.member?(character.eve_id)
end)
end
defp handle_task_result(socket, {:activity_data, activity_data}),
do: MapCharactersEventHandler.handle_activity_data(socket, activity_data)
defp handle_task_result(socket, {:ok, %{type: type} = result})
when type in [
:character_activity,
:character_tracking,
:character_settings,
:character_location,
:character_online,
:character_ship,
:character_fleet
] do
MapCharactersEventHandler.handle_character_result(socket, type, result)
end
defp handle_task_result(socket, _), do: socket
end

View File

@@ -4,7 +4,6 @@ defmodule WandererAppWeb.MapEventHandler do
require Logger
alias WandererAppWeb.{
MapActivityEventHandler,
MapCharactersEventHandler,
MapConnectionsEventHandler,
MapCoreEventHandler,
@@ -85,7 +84,9 @@ defmodule WandererAppWeb.MapEventHandler do
@map_activity_ui_events [
"show_activity",
"hide_activity"
"hide_activity",
"toggle_follow",
"toggle_track"
]
@map_routes_events [
@@ -223,48 +224,36 @@ defmodule WandererAppWeb.MapEventHandler do
def handle_event(socket, event),
do: MapCoreEventHandler.handle_server_event(event, socket)
def handle_ui_event(event, body, socket)
when event in @map_characters_ui_events,
do: MapCharactersEventHandler.handle_ui_event(event, body, socket)
def handle_ui_event(event, body, socket) do
cond do
event in @map_characters_ui_events ->
MapCharactersEventHandler.handle_ui_event(event, body, socket)
def handle_ui_event(event, body, socket)
when event in @map_system_ui_events,
do: MapSystemsEventHandler.handle_ui_event(event, body, socket)
event in @map_system_ui_events ->
MapSystemsEventHandler.handle_ui_event(event, body, socket)
def handle_ui_event(event, body, socket)
when event in @map_connection_ui_events,
do: MapConnectionsEventHandler.handle_ui_event(event, body, socket)
event in @map_connection_ui_events ->
MapConnectionsEventHandler.handle_ui_event(event, body, socket)
def handle_ui_event(event, body, socket)
when event in @map_routes_ui_events,
do: MapRoutesEventHandler.handle_ui_event(event, body, socket)
event in @map_routes_ui_events ->
MapRoutesEventHandler.handle_ui_event(event, body, socket)
def handle_ui_event(event, body, socket)
when event in @map_signatures_ui_events,
do: MapSignaturesEventHandler.handle_ui_event(event, body, socket)
event in @map_signatures_ui_events ->
MapSignaturesEventHandler.handle_ui_event(event, body, socket)
def handle_ui_event(event, body, socket)
when event in @map_structures_ui_events,
do: MapStructuresEventHandler.handle_ui_event(event, body, socket)
event in @map_structures_ui_events ->
MapStructuresEventHandler.handle_ui_event(event, body, socket)
def handle_ui_event(event, body, socket)
when event in @map_activity_ui_events,
do: MapCharactersEventHandler.handle_ui_event(event, body, socket)
event in @map_activity_ui_events ->
MapCharactersEventHandler.handle_ui_event(event, body, socket)
def handle_ui_event(
event,
body,
%{
assigns: %{
is_subscription_active?: true
}
} = socket
)
when event in @map_kills_ui_events,
do: MapKillsEventHandler.handle_ui_event(event, body, socket)
event in @map_kills_ui_events and socket.assigns[:is_subscription_active?] ->
MapKillsEventHandler.handle_ui_event(event, body, socket)
def handle_ui_event(event, body, socket),
do: MapCoreEventHandler.handle_ui_event(event, body, socket)
true ->
MapCoreEventHandler.handle_ui_event(event, body, socket)
end
end
def get_system_static_info(nil), do: nil

View File

@@ -95,8 +95,9 @@ defmodule WandererAppWeb.MapLive do
|> WandererAppWeb.MapEventHandler.handle_event(info)}
@impl true
def handle_event(event, body, socket),
do: WandererAppWeb.MapEventHandler.handle_ui_event(event, body, socket)
def handle_event(event, body, socket) do
WandererAppWeb.MapEventHandler.handle_ui_event(event, body, socket)
end
defp apply_action(socket, :index, _params) do
socket