feat: ability to load characters and their skills from ESI (#25)

This can track multiple characters, and tries to make as few as possible
ESI calls.

In the localstorage, we keep track of all the characters and their
refresh-tokens.
Access-tokens and skills are not stored in the localstorage, which means
that every reload, this information is fetched from ESI again.
This commit is contained in:
Patric Stout
2023-11-26 16:51:11 +01:00
committed by GitHub
parent 012a2597f6
commit 92298f2ad8
11 changed files with 458 additions and 1 deletions

View File

@@ -19,7 +19,8 @@
"license": "MIT",
"dependencies": {
"@eveshipfit/dogma-engine": "^2.2.1",
"clsx": "^2.0.0"
"clsx": "^2.0.0",
"jwt-decode": "^4.0.0"
},
"devDependencies": {
"@babel/preset-env": "^7.23.3",

View File

@@ -0,0 +1,25 @@
.character {
width: 100%;
}
.character > select {
background-color: #1d1d1d;
color: #c5c5c5;
height: 24px;
width: calc(100% - 20px);
}
.character > button {
background-color: #1d1d1d;
color: #c5c5c5;
cursor: pointer;
height: 24px;
text-align: center;
width: 20px;
}
.character > button.noCharacter {
text-align: left;
padding-left: 5px;
width: 100%;
}

View File

@@ -0,0 +1,28 @@
import type { Decorator, Meta, StoryObj } from '@storybook/react';
import React from "react";
import { EsiProvider } from '../EsiProvider';
import { EsiCharacterSelection } from './';
const meta: Meta<typeof EsiCharacterSelection> = {
component: EsiCharacterSelection,
tags: ['autodocs'],
title: 'Component/EsiCharacterSelection',
};
export default meta;
type Story = StoryObj<typeof EsiCharacterSelection>;
const withEsiProvider: Decorator<Record<string, never>> = (Story) => {
return (
<EsiProvider>
<Story />
</EsiProvider>
);
}
export const Default: Story = {
args: {
},
decorators: [withEsiProvider],
};

View File

@@ -0,0 +1,34 @@
import React from "react";
import { EsiContext } from "../EsiProvider";
import styles from "./EsiCharacterSelection.module.css";
/**
* Character selection for EsiProvider.
*
* It shows both a dropdown for all the characters that the EsiProvider knows,
* and a button to add another character.
*/
export const EsiCharacterSelection = () => {
const esi = React.useContext(EsiContext);
if (Object.keys(esi.characters ?? {}).length === 0) {
return <div className={styles.character}>
<button className={styles.noCharacter} onClick={esi.login}>
Login to load characters skills and fits
</button>
</div>
}
return <div className={styles.character}>
<select onChange={e => esi.changeCharacter(e.target.value)} value={esi.currentCharacter}>
{Object.entries(esi.characters).map(([id, name]) => {
return <option key={id} value={id}>{name.name}</option>
})}
</select>
<button onClick={esi.login} title="Add another character">
+
</button>
</div>
};

View File

@@ -0,0 +1 @@
export { EsiCharacterSelection } from "./EsiCharacterSelection";

View File

@@ -0,0 +1,20 @@
export async function getAccessToken(refreshToken: string): Promise<string | undefined> {
let response;
try {
response = await fetch('https://esi.eveship.fit/', {
method: 'POST',
body: JSON.stringify({
refresh_token: refreshToken,
}),
});
} catch (e) {
return undefined;
}
if (response.status !== 201) {
return undefined;
}
const data = await response.json();
return data.access_token;
};

View File

@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from '@storybook/react';
import React from "react";
import { EsiContext, EsiProvider } from './';
const meta: Meta<typeof EsiProvider> = {
component: EsiProvider,
tags: ['autodocs'],
title: 'Provider/EsiProvider',
};
export default meta;
type Story = StoryObj<typeof EsiProvider>;
const TestEsi = () => {
const esi = React.useContext(EsiContext);
if (!esi.loaded) {
return (
<div>
Esi: loading<br/>
</div>
);
}
return (
<div>
Esi: loaded<br/>
<pre>{JSON.stringify(esi, null, 2)}</pre>
</div>
);
}
export const Default: Story = {
args: {
},
render: (args) => (
<EsiProvider {...args}>
<TestEsi />
</EsiProvider>
),
};

View File

@@ -0,0 +1,277 @@
import { jwtDecode } from "jwt-decode";
import React from "react";
import { getAccessToken } from "./EsiAccessToken";
import { getSkills } from "./EsiSkills";
export interface EsiCharacter {
name: string;
skills?: Record<string, number>;
}
export interface Esi {
loaded?: boolean;
characters: Record<string, EsiCharacter>;
currentCharacter?: string;
changeCharacter: (character: string) => void;
login: () => void;
}
interface EsiPrivate {
loaded?: boolean;
refreshTokens: Record<string, string>;
accessTokens: Record<string, string>;
}
interface JwtPayload {
name: string;
sub: string;
}
export const EsiContext = React.createContext<Esi>({
loaded: undefined,
characters: {},
changeCharacter: () => {},
login: () => {},
});
export interface EsiProps {
/** Children that can use this provider. */
children: React.ReactNode;
}
const useLocalStorage = function <T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = React.useState<T>(() => {
if (typeof window === 'undefined') return initialValue;
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
});
const setValue = React.useCallback((value: T | ((val: T) => T)) => {
if (typeof window === 'undefined') return;
if (storedValue == value) return;
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (valueToStore === undefined) {
window.localStorage.removeItem(key);
return;
}
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}, [key, storedValue]);
return [ storedValue, setValue ] as const;
}
/**
* Keeps track (in local storage) of ESI characters and their refresh token.
*/
export const EsiProvider = (props: EsiProps) => {
const [esi, setEsi] = React.useState<Esi>({
loaded: undefined,
characters: {},
changeCharacter: () => {},
login: () => {},
});
const [esiPrivate, setEsiPrivate] = React.useState<EsiPrivate>({
loaded: undefined,
refreshTokens: {},
accessTokens: {},
});
const [characters, setCharacters] = useLocalStorage<Record<string, EsiCharacter>>('characters', {});
const [refreshTokens, setRefreshTokens] = useLocalStorage('refreshTokens', {});
const [currentCharacter, setCurrentCharacter] = useLocalStorage<string | undefined>('currentCharacter', undefined);
const changeCharacter = React.useCallback((character: string) => {
setCurrentCharacter(character);
setEsi((oldEsi: Esi) => {
return {
...oldEsi,
currentCharacter: character,
};
});
}, [setCurrentCharacter]);
const login = React.useCallback(() => {
if (typeof window === 'undefined') return;
window.location.href = "https://esi.eveship.fit/";
}, []);
const ensureAccessToken = React.useCallback(async (characterId: string): Promise<string | undefined> => {
if (esiPrivate.accessTokens[characterId]) {
return esiPrivate.accessTokens[characterId];
}
const accessToken = await getAccessToken(esiPrivate.refreshTokens[characterId]);
if (accessToken === undefined) {
console.log('Failed to get access token');
return undefined;
}
/* New access token; store for later use. */
setEsiPrivate((oldEsiPrivate: EsiPrivate) => {
return {
...oldEsiPrivate,
accessToken: {
...oldEsiPrivate.accessTokens,
[characterId]: accessToken,
},
};
});
return accessToken;
}, [esiPrivate.accessTokens, esiPrivate.refreshTokens]);
React.useEffect(() => {
const characterId = esi.currentCharacter;
if (characterId === undefined) return;
/* Skills already fetched? We won't do it again till the user reloads. */
if (esi.characters[characterId]?.skills !== undefined) return;
ensureAccessToken(characterId).then((accessToken) => {
if (accessToken === undefined) return;
getSkills(characterId, accessToken).then((skills) => {
if (skills === undefined) return;
setEsi((oldEsi: Esi) => {
return {
...oldEsi,
characters: {
...oldEsi.characters,
[characterId]: {
...oldEsi.characters[characterId],
skills,
},
},
};
});
});
});
/* We only update when currentCharacter changes, and ignore all others. */
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [esi.currentCharacter]);
React.useEffect(() => {
if (typeof window === 'undefined') return;
async function loginCharacter(code: string) {
let response;
try {
response = await fetch('https://esi.eveship.fit/', {
method: 'POST',
body: JSON.stringify({
code: code,
}),
});
} catch (e) {
return false;
}
if (response.status !== 201) {
return false;
}
const data = await response.json();
/* Decode the access-token as it contains the name and character id. */
const jwt = jwtDecode<JwtPayload>(data.access_token);
if (!jwt.name || !jwt.sub?.startsWith("CHARACTER:EVE:")) {
return false;
}
const accessToken = data.access_token;
const refreshToken = data.refresh_token;
const name = jwt.name;
const characterId = jwt.sub.slice("CHARACTER:EVE:".length);
/* Update the local storage with the new character. */
setCharacters((oldCharacters: Record<string, EsiCharacter>) => {
return {
...oldCharacters,
[characterId]: {
name: name,
},
};
});
setRefreshTokens((oldRefreshTokens: Record<string, string>) => {
return {
...oldRefreshTokens,
[characterId]: refreshToken,
};
});
setCurrentCharacter(characterId);
/* Update the current render with the new character. */
setEsi((oldEsi: Esi) => {
return {
...oldEsi,
characters: {
...oldEsi.characters,
[characterId]: {
name: name,
},
},
currentCharacter: characterId,
};
});
setEsiPrivate((oldEsiPrivate: EsiPrivate) => {
return {
...oldEsiPrivate,
refreshTokens: {
...oldEsiPrivate.refreshTokens,
[characterId]: refreshToken,
},
accessToken: {
...oldEsiPrivate.accessTokens,
[characterId]: accessToken,
},
};
});
return true;
}
async function startup() {
setEsi({
loaded: true,
characters,
currentCharacter,
changeCharacter,
login,
});
setEsiPrivate({
loaded: true,
refreshTokens,
accessTokens: {},
});
/* Check if this was a login request. */
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (code) {
/* Remove the code from the URL. */
window.history.replaceState(null, "", window.location.pathname + window.location.hash);
if (!await loginCharacter(code)) {
console.log('Failed to login character');
}
}
}
startup();
/* This should only on first start. */
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <EsiContext.Provider value={esi}>
{props.children}
</EsiContext.Provider>
};

View File

@@ -0,0 +1,25 @@
export async function getSkills(characterId: string, accessToken: string): Promise<Record<string, number> | undefined> {
let response;
try {
response = await fetch(`https://esi.evetech.net/v4/characters/${characterId}/skills/`, {
headers: {
authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
});
} catch (e) {
return undefined;
}
if (response.status !== 200) return undefined;
const data = await response.json();
const skills: Record<string, number> = {};
for (const skill of data.skills) {
skills[skill.skill_id] = skill.active_skill_level;
}
return skills;
}

2
src/EsiProvider/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { EsiContext, EsiProvider } from "./EsiProvider";
export type { EsiCharacter, Esi } from "./EsiProvider";

View File

@@ -1,4 +1,6 @@
export * from './DogmaEngineProvider';
export * from './EsiCharacterSelection';
export * from './EsiProvider';
export * from './EveDataProvider';
export * from './EveShipFitHash';
export * from './EveShipFitLink';